├── .styleci.yml
├── .gitignore
├── src
├── config
│ └── revisionable.php
├── Venturecraft
│ └── Revisionable
│ │ ├── RevisionableServiceProvider.php
│ │ ├── FieldFormatter.php
│ │ ├── Revision.php
│ │ ├── Revisionable.php
│ │ └── RevisionableTrait.php
└── migrations
│ └── 2013_04_09_062329_create_revisions_table.php
├── CONTRIBUTING.md
├── tests
├── Models
│ └── User.php
├── migrations
│ ├── 2020_01_02_062330_add_additional_field_to_users.php
│ └── 2020_01_02_062329_add_additional_field_to_revisions.php
└── RevisionTest.php
├── phpunit.xml
├── LICENSE
├── composer.json
└── readme.md
/.styleci.yml:
--------------------------------------------------------------------------------
1 | preset: psr2
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /vendor
2 | composer.lock
3 | /.idea
4 | .phpunit.result.cache
5 |
--------------------------------------------------------------------------------
/src/config/revisionable.php:
--------------------------------------------------------------------------------
1 | Venturecraft\Revisionable\Revision::class,
10 |
11 | 'additional_fields' => [],
12 |
13 | ];
14 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contribution Guidelines
2 |
3 | Contributions are encouraged and welcome; to keep things organised, all bugs and requests should be
4 | opened in the GitHub "Issues" tab for the main project, at [venturecraft/revisionable/issues](https://github.com/venturecraft/revisionable/issues)
5 |
6 | Please submit all pull requests to the [revisionable/develop](https://github.com/VentureCraft/revisionable/tree/develop) branch, so they can be tested before being merged into the master branch.
7 |
--------------------------------------------------------------------------------
/tests/Models/User.php:
--------------------------------------------------------------------------------
1 | string('additional_field')->nullable();
17 | });
18 | }
19 |
20 | /**
21 | * Reverse the migrations.
22 | *
23 | * @return void
24 | */
25 | public function down()
26 | {
27 | //
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/tests/migrations/2020_01_02_062329_add_additional_field_to_revisions.php:
--------------------------------------------------------------------------------
1 | string('additional_field')->nullable();
17 | });
18 | }
19 |
20 | /**
21 | * Reverse the migrations.
22 | *
23 | * @return void
24 | */
25 | public function down()
26 | {
27 | //
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
13 |
14 |
15 |
16 | ./tests/
17 |
18 |
19 |
20 |
21 |
22 | src/
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/src/Venturecraft/Revisionable/RevisionableServiceProvider.php:
--------------------------------------------------------------------------------
1 | publishes([
17 | __DIR__ . '/../../config/revisionable.php' => config_path('revisionable.php'),
18 | ], 'config');
19 |
20 | $this->publishes([
21 | __DIR__ . '/../../migrations/' => database_path('migrations'),
22 | ], 'migrations');
23 | }
24 |
25 | /**
26 | * Register the application services.
27 | *
28 | * @return void
29 | */
30 | public function register()
31 | {
32 | }
33 |
34 | /**
35 | * Get the services provided by the provider.
36 | *
37 | * @return string[]
38 | */
39 | public function provides()
40 | {
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/migrations/2013_04_09_062329_create_revisions_table.php:
--------------------------------------------------------------------------------
1 | bigIncrements('id');
16 | $table->string('revisionable_type');
17 | $table->unsignedBigInteger('revisionable_id');
18 | $table->unsignedBigInteger('user_id')->nullable();
19 | $table->string('key');
20 | $table->text('old_value')->nullable();
21 | $table->text('new_value')->nullable();
22 | $table->timestamps();
23 |
24 | $table->index(array('revisionable_id', 'revisionable_type'));
25 | });
26 | }
27 |
28 | /**
29 | * Reverse the migrations.
30 | *
31 | * @return void
32 | */
33 | public function down()
34 | {
35 | Schema::dropIfExists('revisions');
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2014 Davis Peixoto
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "venturecraft/revisionable",
3 | "license": "MIT",
4 | "description": "Keep a revision history for your models without thinking, created as a package for use with Laravel",
5 | "keywords": [
6 | "model",
7 | "laravel",
8 | "ardent",
9 | "revision",
10 | "audit",
11 | "history"
12 | ],
13 | "homepage": "http://github.com/venturecraft/revisionable",
14 | "authors": [
15 | {
16 | "name": "Chris Duell",
17 | "email": "me@chrisduell.com"
18 | }
19 | ],
20 | "support": {
21 | "issues": "https://github.com/VentureCraft/revisionable/issues",
22 | "source": "https://github.com/VentureCraft/revisionable"
23 | },
24 | "require": {
25 | "php": ">=5.4.0",
26 | "illuminate/support": "~4.0|~5.0|~5.1|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
27 | "laravel/framework": "~5.4|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0"
28 | },
29 | "autoload": {
30 | "classmap": [
31 | "src/migrations"
32 | ],
33 | "psr-0": {
34 | "Venturecraft\\Revisionable": "src/"
35 | }
36 | },
37 | "autoload-dev": {
38 | "psr-4": {
39 | "Venturecraft\\Revisionable\\Tests\\": "tests/"
40 | }
41 | },
42 | "require-dev": {
43 | "orchestra/testbench": "~3.0|^8.0|^9.0|^10.0"
44 | },
45 | "extra": {
46 | "laravel": {
47 | "providers": [
48 | "Venturecraft\\Revisionable\\RevisionableServiceProvider"
49 | ]
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/Venturecraft/Revisionable/FieldFormatter.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 |
13 | /**
14 | * Class FieldFormatter
15 | * @package Venturecraft\Revisionable
16 | */
17 | class FieldFormatter
18 | {
19 | /**
20 | * Format the value according to the provided formats.
21 | *
22 | * @param $key
23 | * @param $value
24 | * @param $formats
25 | *
26 | * @return string formatted value
27 | */
28 | public static function format($key, $value, $formats)
29 | {
30 | foreach ($formats as $pkey => $format) {
31 | $parts = explode(':', $format);
32 | if (sizeof($parts) === 1) {
33 | continue;
34 | }
35 |
36 | if ($pkey == $key) {
37 | $method = array_shift($parts);
38 |
39 | if (method_exists(get_class(), $method)) {
40 | return self::$method($value, implode(':', $parts));
41 | }
42 | break;
43 | }
44 | }
45 |
46 | return $value;
47 | }
48 |
49 | /**
50 | * Check if a field is empty.
51 | *
52 | * @param $value
53 | * @param array $options
54 | *
55 | * @return string
56 | */
57 | public static function isEmpty($value, $options = array())
58 | {
59 | $value_set = isset($value) && $value != '';
60 |
61 | return sprintf(self::boolean($value_set, $options), $value);
62 | }
63 |
64 | /**
65 | * Boolean.
66 | *
67 | * @param $value
68 | * @param array $options The false / true values to return
69 | *
70 | * @return string Formatted version of the boolean field
71 | */
72 | public static function boolean($value, $options = null)
73 | {
74 | if (!is_null($options)) {
75 | $options = explode('|', $options);
76 | }
77 |
78 | if (sizeof($options) != 2) {
79 | $options = array('No', 'Yes');
80 | }
81 |
82 | return $options[!!$value];
83 | }
84 |
85 | /**
86 | * Format the string response, default is to just return the string.
87 | *
88 | * @param $value
89 | * @param $format
90 | *
91 | * @return formatted string
92 | */
93 | public static function string($value, $format = null)
94 | {
95 | if (is_null($format)) {
96 | $format = '%s';
97 | }
98 |
99 | return sprintf($format, $value);
100 | }
101 |
102 | /**
103 | * Format the datetime
104 | *
105 | * @param string $value
106 | * @param string $format
107 | *
108 | * @return formatted datetime
109 | */
110 | public static function datetime($value, $format = 'Y-m-d H:i:s')
111 | {
112 | if (empty($value)) {
113 | return null;
114 | }
115 |
116 | $datetime = new \DateTime($value);
117 |
118 | return $datetime->format($format);
119 | }
120 |
121 | /**
122 | * Format options
123 | *
124 | * @param string $value
125 | * @param string $format
126 | * @return string
127 | */
128 | public static function options($value, $format)
129 | {
130 | $options = explode('|', $format);
131 |
132 | $result = [];
133 |
134 | foreach ($options as $option) {
135 | $transform = explode('.', $option);
136 | $result[$transform[0]] = $transform[1];
137 | }
138 |
139 | if (isset($result[$value])) {
140 | return $result[$value];
141 | }
142 |
143 | return 'undefined';
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/tests/RevisionTest.php:
--------------------------------------------------------------------------------
1 | loadLaravelMigrations(['--database' => 'testing']);
16 |
17 | // call migrations specific to our tests, e.g. to seed the db
18 | // the path option should be an absolute path.
19 | $this->loadMigrationsFrom([
20 | '--database' => 'testing',
21 | '--path' => realpath(__DIR__.'/../src/migrations'),
22 | ]);
23 | }
24 |
25 | /**
26 | * Define environment setup.
27 | *
28 | * @param \Illuminate\Foundation\Application $app
29 | * @return void
30 | */
31 | protected function getEnvironmentSetUp($app)
32 | {
33 | // Setup default database to use sqlite :memory:
34 | $app['config']->set('database.default', 'testbench');
35 | $app['config']->set('database.connections.testbench', array(
36 | 'driver' => 'sqlite',
37 | 'database' => ':memory:',
38 | 'prefix' => '',
39 | ));
40 | }
41 |
42 | /**
43 | * Test we can interact with the database
44 | */
45 | public function testUsersTable()
46 | {
47 | User::create([
48 | 'name' => 'James Judd',
49 | 'email' => 'james.judd@revisionable.test',
50 | 'password' => \Hash::make('456'),
51 | ]);
52 |
53 | $users = User::findOrFail(1);
54 | $this->assertEquals('james.judd@revisionable.test', $users->email);
55 | $this->assertTrue(\Hash::check('456', $users->password));
56 | }
57 |
58 | /**
59 | * Make sure revisions are created
60 | */
61 | public function testRevisionsStored()
62 | {
63 | $user = User::create([
64 | 'name' => 'James Judd',
65 | 'email' => 'james.judd@revisionable.test',
66 | 'password' => \Hash::make('456'),
67 | ]);
68 |
69 | // change to my nickname
70 | $user->update([
71 | 'name' => 'Judd'
72 | ]);
73 |
74 | // change to my forename
75 | $user->update([
76 | 'name' => 'James'
77 | ]);
78 |
79 | // we should have two revisions to my name
80 | $this->assertCount(2, $user->revisionHistory);
81 | }
82 |
83 | /**
84 | * Make sure additional fields are saved with revision
85 | */
86 | public function testRevisionStoredAdditionalFields()
87 | {
88 | $this->loadMigrationsFrom([
89 | '--database' => 'testing',
90 | '--path' => realpath(__DIR__.'/migrations'),
91 | ]);
92 |
93 | $this->app['config']->set('revisionable.additional_fields', ['additional_field']);
94 |
95 | $user = User::create([
96 | 'name' => 'James Judd',
97 | 'email' => 'james.judd@revisionable.test',
98 | 'additional_field' => 678,
99 | 'password' => \Hash::make('456'),
100 | ]);
101 |
102 |
103 | // change to my nickname
104 | $user->update([
105 | 'name' => 'Judd'
106 | ]);
107 |
108 | // we should have two revisions to my name
109 | $this->assertCount(1, $user->revisionHistory);
110 |
111 | $this->assertEquals(678, $user->revisionHistory->first()->additional_field);
112 | }
113 |
114 | /**
115 | * Make sure additional fields without values don't break
116 | */
117 | public function testRevisionSkipsAdditionalFieldsWhenNotAvailable()
118 | {
119 | $this->loadMigrationsFrom([
120 | '--database' => 'testing',
121 | '--path' => realpath(__DIR__.'/migrations'),
122 | ]);
123 |
124 | $this->app['config']->set('revisionable.additional_fields', ['additional_field']);
125 |
126 | $user = User::create([
127 | 'name' => 'James Judd',
128 | 'email' => 'james.judd@revisionable.test',
129 | 'password' => \Hash::make('456'),
130 | ]);
131 |
132 |
133 | // change to my nickname
134 | $user->update([
135 | 'name' => 'Judd'
136 | ]);
137 |
138 | // we should have two revisions to my name
139 | $this->assertCount(1, $user->revisionHistory);
140 |
141 | $this->assertNull($user->revisionHistory->first()->additional_field);
142 | }
143 |
144 | /**
145 | * Make sure additional fields which don't exist on the model still save revision
146 | */
147 | public function testRevisionSkipsAdditionalFieldsWhenMisconfigured()
148 | {
149 | $this->loadMigrationsFrom([
150 | '--database' => 'testing',
151 | '--path' => realpath(__DIR__.'/migrations'),
152 | ]);
153 |
154 | $this->app['config']->set('revisionable.additional_fields', ['unknown_field']);
155 |
156 | $user = User::create([
157 | 'name' => 'James Judd',
158 | 'email' => 'james.judd@revisionable.test',
159 | 'password' => \Hash::make('456'),
160 | ]);
161 |
162 |
163 | // change to my nickname
164 | $user->update([
165 | 'name' => 'Judd'
166 | ]);
167 |
168 | // we should have two revisions to my name
169 | $this->assertCount(1, $user->revisionHistory);
170 | }
171 | }
172 |
--------------------------------------------------------------------------------
/src/Venturecraft/Revisionable/Revision.php:
--------------------------------------------------------------------------------
1 |
16 | */
17 | class Revision extends Eloquent
18 | {
19 | /**
20 | * @var string
21 | */
22 | public $table = 'revisions';
23 |
24 | /**
25 | * @var array
26 | */
27 | protected $revisionFormattedFields = array();
28 |
29 | /**
30 | * @param array $attributes
31 | */
32 | public function __construct(array $attributes = array())
33 | {
34 | parent::__construct($attributes);
35 | }
36 |
37 | /**
38 | * Revisionable.
39 | *
40 | * Grab the revision history for the model that is calling
41 | *
42 | * @return array revision history
43 | */
44 | public function revisionable()
45 | {
46 | return $this->morphTo();
47 | }
48 |
49 | /**
50 | * Field Name
51 | *
52 | * Returns the field that was updated, in the case that it's a foreign key
53 | * denoted by a suffix of "_id", then "_id" is simply stripped
54 | *
55 | * @return string field
56 | */
57 | public function fieldName()
58 | {
59 | if ($formatted = $this->formatFieldName($this->key)) {
60 | return $formatted;
61 | } elseif (strpos($this->key, '_id')) {
62 | return str_replace('_id', '', $this->key);
63 | } else {
64 | return $this->key;
65 | }
66 | }
67 |
68 | /**
69 | * Format field name.
70 | *
71 | * Allow overrides for field names.
72 | *
73 | * @param $key
74 | *
75 | * @return bool
76 | */
77 | private function formatFieldName($key)
78 | {
79 | $related_model = $this->getActualClassNameForMorph($this->revisionable_type);
80 | $related_model = new $related_model;
81 | $revisionFormattedFieldNames = $related_model->getRevisionFormattedFieldNames();
82 |
83 | if (isset($revisionFormattedFieldNames[$key])) {
84 | return $revisionFormattedFieldNames[$key];
85 | }
86 |
87 | return false;
88 | }
89 |
90 | /**
91 | * Old Value.
92 | *
93 | * Grab the old value of the field, if it was a foreign key
94 | * attempt to get an identifying name for the model.
95 | *
96 | * @return string old value
97 | */
98 | public function oldValue()
99 | {
100 | return $this->getValue('old');
101 | }
102 |
103 |
104 | /**
105 | * New Value.
106 | *
107 | * Grab the new value of the field, if it was a foreign key
108 | * attempt to get an identifying name for the model.
109 | *
110 | * @return string old value
111 | */
112 | public function newValue()
113 | {
114 | return $this->getValue('new');
115 | }
116 |
117 |
118 | /**
119 | * Responsible for actually doing the grunt work for getting the
120 | * old or new value for the revision.
121 | *
122 | * @param string $which old or new
123 | *
124 | * @return string value
125 | */
126 | private function getValue($which = 'new')
127 | {
128 | $which_value = $which . '_value';
129 |
130 | // First find the main model that was updated
131 | $main_model = $this->revisionable_type;
132 | // Load it, WITH the related model
133 | if (class_exists($main_model)) {
134 | $main_model = new $main_model;
135 |
136 | try {
137 | if ($this->isRelated()) {
138 | $related_model = $this->getRelatedModel();
139 |
140 | // Now we can find out the namespace of of related model
141 | if (!method_exists($main_model, $related_model)) {
142 | $related_model = Str::camel($related_model); // for cases like published_status_id
143 | if (!method_exists($main_model, $related_model)) {
144 | throw new \Exception('Relation ' . $related_model . ' does not exist for ' . get_class($main_model));
145 | }
146 | }
147 | $related_class = $main_model->$related_model()->getRelated();
148 |
149 | // Finally, now that we know the namespace of the related model
150 | // we can load it, to find the information we so desire
151 | $item = $related_class::find($this->$which_value);
152 |
153 | if (is_null($this->$which_value) || $this->$which_value == '') {
154 | $item = new $related_class;
155 |
156 | return $item->getRevisionNullString();
157 | }
158 | if (!$item) {
159 | $item = new $related_class;
160 |
161 | return $this->format($this->key, $item->getRevisionUnknownString());
162 | }
163 |
164 | // Check if model use RevisionableTrait
165 | if(method_exists($item, 'identifiableName')) {
166 | // see if there's an available mutator
167 | $mutator = 'get' . Str::studly($this->key) . 'Attribute';
168 | if (method_exists($item, $mutator)) {
169 | return $this->format($item->$mutator($this->key), $item->identifiableName());
170 | }
171 |
172 | return $this->format($this->key, $item->identifiableName());
173 | }
174 | }
175 | } catch (\Exception $e) {
176 | // Just a fail-safe, in the case the data setup isn't as expected
177 | // Nothing to do here.
178 | }
179 |
180 | // if there was an issue
181 | // or, if it's a normal value
182 |
183 | $mutator = 'get' . Str::studly($this->key) . 'Attribute';
184 | if (method_exists($main_model, $mutator)) {
185 | return $this->format($this->key, $main_model->$mutator($this->$which_value));
186 | }
187 | }
188 |
189 | return $this->format($this->key, $this->$which_value);
190 | }
191 |
192 | /**
193 | * Return true if the key is for a related model.
194 | *
195 | * @return bool
196 | */
197 | private function isRelated()
198 | {
199 | $isRelated = false;
200 | $idSuffix = '_id';
201 | $pos = strrpos($this->key, $idSuffix);
202 |
203 | if ($pos !== false
204 | && strlen($this->key) - strlen($idSuffix) === $pos
205 | ) {
206 | $isRelated = true;
207 | }
208 |
209 | return $isRelated;
210 | }
211 |
212 | /**
213 | * Return the name of the related model.
214 | *
215 | * @return string
216 | */
217 | private function getRelatedModel()
218 | {
219 | $idSuffix = '_id';
220 |
221 | return substr($this->key, 0, strlen($this->key) - strlen($idSuffix));
222 | }
223 |
224 | /**
225 | * User Responsible.
226 | *
227 | * @return User user responsible for the change
228 | */
229 | public function userResponsible()
230 | {
231 | if (empty($this->user_id)) { return false; }
232 | if (class_exists($class = '\Cartalyst\Sentry\Facades\Laravel\Sentry')
233 | || class_exists($class = '\Cartalyst\Sentinel\Laravel\Facades\Sentinel')
234 | ) {
235 | return $class::findUserById($this->user_id);
236 | } else {
237 | $user_model = app('config')->get('auth.model');
238 |
239 | if (empty($user_model)) {
240 | $user_model = app('config')->get('auth.providers.users.model');
241 | if (empty($user_model)) {
242 | return false;
243 | }
244 | }
245 | if (!class_exists($user_model)) {
246 | return false;
247 | }
248 | return $user_model::find($this->user_id);
249 | }
250 | }
251 |
252 | /**
253 | * Returns the object we have the history of
254 | *
255 | * @return Object|false
256 | */
257 | public function historyOf()
258 | {
259 | if (class_exists($class = $this->revisionable_type)) {
260 | return $class::find($this->revisionable_id);
261 | }
262 |
263 | return false;
264 | }
265 |
266 | /*
267 | * Examples:
268 | array(
269 | 'public' => 'boolean:Yes|No',
270 | 'minimum' => 'string:Min: %s'
271 | )
272 | */
273 | /**
274 | * Format the value according to the $revisionFormattedFields array.
275 | *
276 | * @param $key
277 | * @param $value
278 | *
279 | * @return string formatted value
280 | */
281 | public function format($key, $value)
282 | {
283 | $related_model = $this->getActualClassNameForMorph($this->revisionable_type);
284 | $related_model = new $related_model;
285 | $revisionFormattedFields = $related_model->getRevisionFormattedFields();
286 |
287 | if (isset($revisionFormattedFields[$key])) {
288 | return FieldFormatter::format($key, $value, $revisionFormattedFields);
289 | } else {
290 | return $value;
291 | }
292 | }
293 | }
294 |
--------------------------------------------------------------------------------
/src/Venturecraft/Revisionable/Revisionable.php:
--------------------------------------------------------------------------------
1 |
10 | *
11 | */
12 |
13 | /**
14 | * Class Revisionable
15 | * @package Venturecraft\Revisionable
16 | */
17 | class Revisionable extends Eloquent
18 | {
19 | /**
20 | * @var
21 | */
22 | private $originalData;
23 |
24 | /**
25 | * @var
26 | */
27 | private $updatedData;
28 |
29 | /**
30 | * @var
31 | */
32 | private $updating;
33 |
34 | /**
35 | * @var array
36 | */
37 | private $dontKeep = array();
38 |
39 | /**
40 | * @var array
41 | */
42 | private $doKeep = array();
43 |
44 | /**
45 | * Keeps the list of values that have been updated
46 | *
47 | * @var array
48 | */
49 | protected $dirtyData = array();
50 |
51 | /**
52 | * Create the event listeners for the saving and saved events
53 | * This lets us save revisions whenever a save is made, no matter the
54 | * http method.
55 | */
56 | public static function boot()
57 | {
58 | parent::boot();
59 |
60 | static::saving(function ($model) {
61 | $model->preSave();
62 | });
63 |
64 | static::saved(function ($model) {
65 | $model->postSave();
66 | });
67 |
68 | static::created(function ($model) {
69 | $model->postCreate();
70 | });
71 |
72 | static::deleted(function ($model) {
73 | $model->preSave();
74 | $model->postDelete();
75 | $model->postForceDelete();
76 | });
77 | }
78 | /**
79 | * Instance the revision model
80 | * @return \Illuminate\Database\Eloquent\Model
81 | */
82 | public static function newModel()
83 | {
84 | $model = app('config')->get('revisionable.model');
85 |
86 | if (! $model) {
87 | $model = 'Venturecraft\Revisionable\Revision';
88 | }
89 |
90 | return new $model;
91 | }
92 |
93 | /**
94 | * @return mixed
95 | */
96 | public function revisionHistory()
97 | {
98 | return $this->morphMany(get_class(static::newModel()), 'revisionable');
99 | }
100 |
101 | /**
102 | * Invoked before a model is saved. Return false to abort the operation.
103 | *
104 | * @return bool
105 | */
106 | public function preSave()
107 | {
108 | if (!isset($this->revisionEnabled) || $this->revisionEnabled) {
109 | // if there's no revisionEnabled. Or if there is, if it's true
110 |
111 | $this->originalData = $this->original;
112 | $this->updatedData = $this->attributes;
113 |
114 | // we can only safely compare basic items,
115 | // so for now we drop any object based items, like DateTime
116 | foreach ($this->updatedData as $key => $val) {
117 | if (gettype($val) == 'object' && ! method_exists($val, '__toString')) {
118 | unset($this->originalData[$key]);
119 | unset($this->updatedData[$key]);
120 | }
121 | }
122 |
123 | // the below is ugly, for sure, but it's required so we can save the standard model
124 | // then use the keep / dontkeep values for later, in the isRevisionable method
125 | $this->dontKeep = isset($this->dontKeepRevisionOf) ?
126 | array_merge($this->dontKeepRevisionOf, $this->dontKeep)
127 | : $this->dontKeep;
128 |
129 | $this->doKeep = isset($this->keepRevisionOf) ?
130 | array_merge($this->keepRevisionOf, $this->doKeep)
131 | : $this->doKeep;
132 |
133 | unset($this->attributes['dontKeepRevisionOf']);
134 | unset($this->attributes['keepRevisionOf']);
135 |
136 | $this->dirtyData = $this->getDirty();
137 | $this->updating = $this->exists;
138 | }
139 | }
140 |
141 |
142 | /**
143 | * Called after a model is successfully saved.
144 | *
145 | * @return void
146 | */
147 | public function postSave()
148 | {
149 |
150 | // check if the model already exists
151 | if ((!isset($this->revisionEnabled) || $this->revisionEnabled) && $this->updating) {
152 | // if it does, it means we're updating
153 |
154 | $changes_to_record = $this->changedRevisionableFields();
155 |
156 | $revisions = array();
157 |
158 | foreach ($changes_to_record as $key => $change) {
159 | $revisions[] = array(
160 | 'revisionable_type' => $this->getMorphClass(),
161 | 'revisionable_id' => $this->getKey(),
162 | 'key' => $key,
163 | 'old_value' => Arr::get($this->originalData, $key),
164 | 'new_value' => $this->updatedData[$key],
165 | 'user_id' => $this->getSystemUserId(),
166 | 'created_at' => new \DateTime(),
167 | 'updated_at' => new \DateTime(),
168 | );
169 | }
170 |
171 | if (count($revisions) > 0) {
172 | $revision = static::newModel();
173 | \DB::table($revision->getTable())->insert($revisions);
174 | }
175 | }
176 | }
177 |
178 | /**
179 | * Called after record successfully created
180 | */
181 | public function postCreate()
182 | {
183 |
184 | // Check if we should store creations in our revision history
185 | // Set this value to true in your model if you want to
186 | if(empty($this->revisionCreationsEnabled))
187 | {
188 | // We should not store creations.
189 | return false;
190 | }
191 |
192 | if ((!isset($this->revisionEnabled) || $this->revisionEnabled))
193 | {
194 | $revisions[] = array(
195 | 'revisionable_type' => $this->getMorphClass(),
196 | 'revisionable_id' => $this->getKey(),
197 | 'key' => self::CREATED_AT,
198 | 'old_value' => null,
199 | 'new_value' => $this->{self::CREATED_AT},
200 | 'user_id' => $this->getSystemUserId(),
201 | 'created_at' => new \DateTime(),
202 | 'updated_at' => new \DateTime(),
203 | );
204 |
205 | $revision = static::newModel();
206 | \DB::table($revision->getTable())->insert($revisions);
207 | }
208 | }
209 |
210 | /**
211 | * If softdeletes are enabled, store the deleted time
212 | */
213 | public function postDelete()
214 | {
215 | if ((!isset($this->revisionEnabled) || $this->revisionEnabled)
216 | && $this->isSoftDelete()
217 | && $this->isRevisionable($this->getDeletedAtColumn())) {
218 | $revisions[] = array(
219 | 'revisionable_type' => $this->getMorphClass(),
220 | 'revisionable_id' => $this->getKey(),
221 | 'key' => $this->getDeletedAtColumn(),
222 | 'old_value' => null,
223 | 'new_value' => $this->{$this->getDeletedAtColumn()},
224 | 'user_id' => $this->getSystemUserId(),
225 | 'created_at' => new \DateTime(),
226 | 'updated_at' => new \DateTime(),
227 | );
228 | $revision = static::newModel();
229 | \DB::table($revision->getTable())->insert($revisions);
230 | }
231 | }
232 |
233 | /**
234 | * If forcedeletes are enabled, set the value created_at of model to null
235 | *
236 | * @return void|bool
237 | */
238 | public function postForceDelete()
239 | {
240 | if (empty($this->revisionForceDeleteEnabled)) {
241 | return false;
242 | }
243 |
244 | if ((!isset($this->revisionEnabled) || $this->revisionEnabled)
245 | && (($this->isSoftDelete() && $this->isForceDeleting()) || !$this->isSoftDelete())) {
246 |
247 | $revisions[] = array(
248 | 'revisionable_type' => $this->getMorphClass(),
249 | 'revisionable_id' => $this->getKey(),
250 | 'key' => self::CREATED_AT,
251 | 'old_value' => $this->{self::CREATED_AT},
252 | 'new_value' => null,
253 | 'user_id' => $this->getSystemUserId(),
254 | 'created_at' => new \DateTime(),
255 | 'updated_at' => new \DateTime(),
256 | );
257 |
258 | $revision = Revisionable::newModel();
259 | \DB::table($revision->getTable())->insert($revisions);
260 | \Event::dispatch('revisionable.deleted', array('model' => $this, 'revisions' => $revisions));
261 | }
262 | }
263 |
264 | /**
265 | * Attempt to find the user id of the currently logged in user
266 | * Supports Cartalyst Sentry/Sentinel based authentication, as well as stock Auth
267 | **/
268 | private function getSystemUserId()
269 | {
270 | try {
271 | if (class_exists($class = '\Cartalyst\Sentry\Facades\Laravel\Sentry')
272 | || class_exists($class = '\Cartalyst\Sentinel\Laravel\Facades\Sentinel')) {
273 | return ($class::check()) ? $class::getUser()->id : null;
274 | } elseif (function_exists('backpack_auth') && backpack_auth()->check()) {
275 | return backpack_user()->id;
276 | } elseif (\Auth::check()) {
277 | return \Auth::user()->getAuthIdentifier();
278 | }
279 | } catch (\Exception $e) {
280 | return null;
281 | }
282 |
283 | return null;
284 | }
285 |
286 | /**
287 | * Get all of the changes that have been made, that are also supposed
288 | * to have their changes recorded
289 | *
290 | * @return array fields with new data, that should be recorded
291 | */
292 | private function changedRevisionableFields()
293 | {
294 | $changes_to_record = array();
295 | foreach ($this->dirtyData as $key => $value) {
296 | // check that the field is revisionable, and double check
297 | // that it's actually new data in case dirty is, well, clean
298 | if ($this->isRevisionable($key) && !is_array($value)) {
299 | if (!isset($this->originalData[$key]) || $this->originalData[$key] != $this->updatedData[$key]) {
300 | $changes_to_record[$key] = $value;
301 | }
302 | } else {
303 | // we don't need these any more, and they could
304 | // contain a lot of data, so lets trash them.
305 | unset($this->updatedData[$key]);
306 | unset($this->originalData[$key]);
307 | }
308 | }
309 |
310 | return $changes_to_record;
311 | }
312 |
313 | /**
314 | * Check if this field should have a revision kept
315 | *
316 | * @param string $key
317 | *
318 | * @return bool
319 | */
320 | private function isRevisionable($key)
321 | {
322 |
323 | // If the field is explicitly revisionable, then return true.
324 | // If it's explicitly not revisionable, return false.
325 | // Otherwise, if neither condition is met, only return true if
326 | // we aren't specifying revisionable fields.
327 | if (isset($this->doKeep) && in_array($key, $this->doKeep)) {
328 | return true;
329 | }
330 | if (isset($this->dontKeep) && in_array($key, $this->dontKeep)) {
331 | return false;
332 | }
333 | return empty($this->doKeep);
334 | }
335 |
336 | /**
337 | * Check if soft deletes are currently enabled on this model
338 | *
339 | * @return bool
340 | */
341 | private function isSoftDelete()
342 | {
343 | // check flag variable used in laravel 4.2+
344 | if (isset($this->forceDeleting)) {
345 | return !$this->forceDeleting;
346 | }
347 |
348 | // otherwise, look for flag used in older versions
349 | if (isset($this->softDelete)) {
350 | return $this->softDelete;
351 | }
352 |
353 | return false;
354 | }
355 |
356 | /**
357 | * @return mixed
358 | */
359 | public function getRevisionFormattedFields()
360 | {
361 | return $this->revisionFormattedFields;
362 | }
363 |
364 | /**
365 | * @return mixed
366 | */
367 | public function getRevisionFormattedFieldNames()
368 | {
369 | return $this->revisionFormattedFieldNames;
370 | }
371 |
372 | /**
373 | * Identifiable Name
374 | * When displaying revision history, when a foreign key is updated
375 | * instead of displaying the ID, you can choose to display a string
376 | * of your choice, just override this method in your model
377 | * By default, it will fall back to the models ID.
378 | *
379 | * @return string an identifying name for the model
380 | */
381 | public function identifiableName()
382 | {
383 | return $this->getKey();
384 | }
385 |
386 | /**
387 | * Revision Unknown String
388 | * When displaying revision history, when a foreign key is updated
389 | * instead of displaying the ID, you can choose to display a string
390 | * of your choice, just override this method in your model
391 | * By default, it will fall back to the models ID.
392 | *
393 | * @return string an identifying name for the model
394 | */
395 | public function getRevisionNullString()
396 | {
397 | return isset($this->revisionNullString)?$this->revisionNullString:'nothing';
398 | }
399 |
400 | /**
401 | * No revision string
402 | * When displaying revision history, if the revisions value
403 | * cant be figured out, this is used instead.
404 | * It can be overridden.
405 | *
406 | * @return string an identifying name for the model
407 | */
408 | public function getRevisionUnknownString()
409 | {
410 | return isset($this->revisionUnknownString)?$this->revisionUnknownString:'unknown';
411 | }
412 |
413 | /**
414 | * Disable a revisionable field temporarily
415 | * Need to do the adding to array longhanded, as there's a
416 | * PHP bug https://bugs.php.net/bug.php?id=42030
417 | *
418 | * @param mixed $field
419 | *
420 | * @return void
421 | */
422 | public function disableRevisionField($field)
423 | {
424 | if (!isset($this->dontKeepRevisionOf)) {
425 | $this->dontKeepRevisionOf = array();
426 | }
427 | if (is_array($field)) {
428 | foreach ($field as $one_field) {
429 | $this->disableRevisionField($one_field);
430 | }
431 | } else {
432 | $donts = $this->dontKeepRevisionOf;
433 | $donts[] = $field;
434 | $this->dontKeepRevisionOf = $donts;
435 | unset($donts);
436 | }
437 | }
438 | }
439 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | [](https://packagist.org/packages/venturecraft/revisionable)
4 | [](https://packagist.org/packages/venturecraft/revisionable)
5 | [](https://tldrlegal.com/license/mit-license)
6 |
7 | Wouldn't it be nice to have a revision history for any model in your project, without having to do any work for it. By simply adding the `RevisionableTrait` Trait to your model, you can instantly have just that, and be able to display a history similar to this:
8 |
9 | * Chris changed title from 'Something' to 'Something else'
10 | * Chris changed category from 'News' to 'Breaking news'
11 | * Matt changed category from 'Breaking news' to 'News'
12 |
13 | So not only can you see a history of what happened, but who did what, so there's accountability.
14 |
15 | Revisionable is a laravel package that allows you to keep a revision history for your models without thinking. For some background and info, [see this article](http://www.chrisduell.com/blog/development/keeping-revisions-of-your-laravel-model-data/)
16 |
17 | ## Working with 3rd party Auth / Eloquent extensions
18 |
19 | Revisionable has support for Auth powered by
20 | * [**Sentry by Cartalyst**](https://cartalyst.com/manual/sentry).
21 | * [**Sentinel by Cartalyst**](https://cartalyst.com/manual/sentinel).
22 |
23 | *(Recommended)* Revisionable can also now be used [as a Trait](#the-new-trait-based-implementation), so your models can continue to extend Eloquent, or any other class that extends Eloquent (like [Ardent](https://github.com/laravelbook/ardent)).
24 |
25 | ## Installation
26 |
27 | Revisionable is installable via [composer](https://getcomposer.org/doc/00-intro.md), the details are on [packagist, here.](https://packagist.org/packages/venturecraft/revisionable)
28 |
29 | Add the following to the `require` section of your projects composer.json file:
30 |
31 | ```php
32 | "venturecraft/revisionable": "1.*",
33 | ```
34 |
35 | Run composer update to download the package
36 |
37 | ```
38 | php composer.phar update
39 | ```
40 |
41 | Open config/app.php and register the required service provider (Laravel 5.x)
42 |
43 | ```
44 | 'providers' => [
45 | Venturecraft\Revisionable\RevisionableServiceProvider::class,
46 | ]
47 | ```
48 |
49 | Publish the configuration and migrations (Laravel 5.x)
50 |
51 | ```
52 | php artisan vendor:publish --provider="Venturecraft\Revisionable\RevisionableServiceProvider"
53 | ```
54 |
55 | Finally, you'll also need to run migration on the package (Laravel 5.x)
56 |
57 | ```
58 | php artisan migrate
59 | ```
60 |
61 | For Laravel 4.x users:
62 | ```
63 | php artisan migrate --package=venturecraft/revisionable
64 | ```
65 |
66 | > If you're going to be migrating up and down completely a lot (using `migrate:refresh`), one thing you can do instead is to copy the migration file from the package to your `app/database` folder, and change the classname from `CreateRevisionsTable` to something like `CreateRevisionTable` (without the 's', otherwise you'll get an error saying there's a duplicate class)
67 |
68 | > `cp vendor/venturecraft/revisionable/src/migrations/2013_04_09_062329_create_revisions_table.php database/migrations/`
69 |
70 | ## Docs
71 |
72 | * [Implementation](#intro)
73 | * [More control](#control)
74 | * [Format output](#formatoutput)
75 | * [Load revision history](#loadhistory)
76 | * [Display history](#display)
77 | * [Contributing](#contributing)
78 | * [Having troubles?](#faq)
79 |
80 |
81 | ## Implementation
82 |
83 | ### The new, Trait based implementation (recommended)
84 | > Traits require PHP >= 5.4
85 |
86 | For any model that you want to keep a revision history for, include the `VentureCraft\Revisionable` namespace and use the `RevisionableTrait` in your model, e.g.,
87 |
88 | ```php
89 | namespace App;
90 |
91 | use \Venturecraft\Revisionable\RevisionableTrait;
92 |
93 | class Article extends \Illuminate\Database\Eloquent\Model {
94 | use RevisionableTrait;
95 | }
96 | ```
97 |
98 | > Being a trait, Revisionable can now be used with the standard Eloquent model, or any class that extends Eloquent, such as [Ardent](https://github.com/laravelbook/ardent).
99 |
100 | ### Legacy class based implementation
101 |
102 | > The new trait based approach is backwards compatible with existing installations of Revisionable. You can still use the below installation instructions, which essentially is extending a wrapper for the trait.
103 |
104 | For any model that you want to keep a revision history for, include the `VentureCraft\Revisionable` namespace and use the `RevisionableTrait` in your model, e.g.,
105 |
106 | ```php
107 | use Venturecraft\Revisionable\Revisionable;
108 |
109 | namespace App;
110 |
111 | class Article extends Revisionable { }
112 | ```
113 |
114 | > Note: This also works with namespaced models.
115 |
116 | ### Implementation notes
117 |
118 | If needed, you can disable the revisioning by setting `$revisionEnabled` to false in your Model. This can be handy if you want to temporarily disable revisioning, or if you want to create your own base Model that extends Revisionable, which all of your models extend, but you want to turn Revisionable off for certain models.
119 |
120 | ```php
121 | namespace App;
122 |
123 | use \Venturecraft\Revisionable\RevisionableTrait;
124 |
125 | class Article extends \Illuminate\Database\Eloquent\Model {
126 | protected $revisionEnabled = false;
127 | }
128 | ```
129 |
130 | You can also disable revisioning after X many revisions have been made by setting `$historyLimit` to the number of revisions you want to keep before stopping revisions.
131 |
132 | ```php
133 | namespace App;
134 |
135 | use \Venturecraft\Revisionable\RevisionableTrait;
136 |
137 | class Article extends \Illuminate\Database\Eloquent\Model {
138 | protected $revisionEnabled = true;
139 | protected $historyLimit = 500; //Stop tracking revisions after 500 changes have been made.
140 | }
141 | ```
142 | In order to maintain a limit on history, but instead of stopping tracking revisions if you want to remove old revisions, you can accommodate that feature by setting `$revisionCleanup`.
143 |
144 | ```php
145 | namespace App;
146 |
147 | use \Venturecraft\Revisionable\RevisionableTrait;
148 |
149 | class Article extends \Illuminate\Database\Eloquent\Model {
150 | protected $revisionEnabled = true;
151 | protected $revisionCleanup = true; //Remove old revisions (works only when used with $historyLimit)
152 | protected $historyLimit = 500; //Maintain a maximum of 500 changes at any point of time, while cleaning up old revisions.
153 | }
154 | ```
155 |
156 | ### Storing Soft Deletes
157 | By default, if your model supports soft deletes, Revisionable will store this and any restores as updates on the model.
158 |
159 | You can choose to ignore deletes and restores by adding `deleted_at` to your `$dontKeepRevisionOf` array.
160 |
161 | To better format the output for `deleted_at` entries, you can use the `isEmpty` formatter (see Format output for an example of this.)
162 |
163 |
164 |
165 | ### Storing Force Delete
166 | By default the Force Delete of a model is not stored as a revision.
167 |
168 | If you want to store the Force Delete as a revision you can override this behavior by setting `revisionForceDeleteEnabled ` to `true` by adding the following to your model:
169 | ```php
170 | protected $revisionForceDeleteEnabled = true;
171 | ```
172 |
173 | In which case, the `created_at` field will be stored as a key with the `oldValue()` value equal to the model creation date and the `newValue()` value equal to `null`.
174 |
175 | **Attention!** Turn on this setting carefully! Since the model saved in the revision, now does not exist, so you will not be able to get its object or its relations.
176 |
177 | ### Storing Creations
178 | By default the creation of a new model is not stored as a revision.
179 | Only subsequent changes to a model is stored.
180 |
181 | If you want to store the creation as a revision you can override this behavior by setting `revisionCreationsEnabled` to `true` by adding the following to your model:
182 | ```php
183 | protected $revisionCreationsEnabled = true;
184 | ```
185 |
186 | ## More Control
187 |
188 | No doubt, there'll be cases where you don't want to store a revision history only for certain fields of the model, this is supported in two different ways. In your model you can either specifiy which fields you explicitly want to track and all other fields are ignored:
189 |
190 | ```php
191 | protected $keepRevisionOf = ['title'];
192 | ```
193 |
194 | Or, you can specify which fields you explicitly don't want to track. All other fields will be tracked.
195 |
196 | ```php
197 | protected $dontKeepRevisionOf = ['category_id'];
198 | ```
199 |
200 | > The `$keepRevisionOf` setting takes precedence over `$dontKeepRevisionOf`
201 |
202 | ### Storing additional fields in revisions
203 |
204 | In some cases, you'll want additional metadata from the models in each revision. An example of this might be if you
205 | have to keep track of accounts as well as users. Simply create your own new migration to add the fields you'd like to your revision model,
206 | add them to your config/revisionable.php in an array like so:
207 |
208 | ```php
209 | 'additional_fields' => ['account_id', 'permissions_id', 'other_id'],
210 | ```
211 |
212 | If the column exists in the model, it will be included in the revision.
213 |
214 | Make sure that if you can't guarantee the column in every model, you make that column ```nullable()``` in your migrations.
215 |
216 |
217 | ### Events
218 |
219 | Every time a model revision is created an event is fired. You can listen for `revisionable.created`,
220 | `revisionable.saved` or `revisionable.deleted`.
221 |
222 | ```php
223 | // app/Providers/EventServiceProvider.php
224 |
225 | public function boot()
226 | {
227 | parent::boot();
228 |
229 | $events->listen('revisionable.*', function($model, $revisions) {
230 | // Do something with the revisions or the changed model.
231 | dd($model, $revisions);
232 | });
233 | }
234 |
235 | ```
236 |
237 |
238 | ## Format output
239 |
240 | > You can continue (and are encouraged to) use `Eloquent accessors` in your model to set the
241 | output of your values, see the [Laravel Documentation for more information on accessors](https://laravel.com/docs/eloquent-mutators#accessors-and-mutators)
242 | > The below documentation is therefor deprecated
243 |
244 | In cases where you want to have control over the format of the output of the values, for example a boolean field, you can set them in the `$revisionFormattedFields` array in your model. e.g.,
245 |
246 | ```php
247 | protected $revisionFormattedFields = [
248 | 'title' => 'string:%s',
249 | 'public' => 'boolean:No|Yes',
250 | 'modified' => 'datetime:m/d/Y g:i A',
251 | 'deleted_at' => 'isEmpty:Active|Deleted'
252 | ];
253 | ```
254 |
255 | You can also override the field name output using the `$revisionFormattedFieldNames` array in your model, e.g.,
256 |
257 | ```php
258 | protected $revisionFormattedFieldNames = [
259 | 'title' => 'Title',
260 | 'small_name' => 'Nickname',
261 | 'deleted_at' => 'Deleted At'
262 | ];
263 | ```
264 |
265 | This comes into play when you output the revision field name using `$revision->fieldName()`
266 |
267 | ### String
268 | To format a string, simply prefix the value with `string:` and be sure to include `%s` (this is where the actual value will appear in the formatted response), e.g.,
269 |
270 | ```
271 | string:%s
272 | ```
273 |
274 | ### Boolean
275 | Booleans by default will display as a 0 or a 1, which is pretty bland and won't mean much to the end user, so this formatter can be used to output something a bit nicer. Prefix the value with `boolean:` and then add your false and true options separated by a pipe, e.g.,
276 |
277 | ```
278 | boolean:No|Yes
279 | ```
280 |
281 | ### Options
282 | Analogous to "boolean", only any text or numeric values can act as a source value (often flags are stored in the database). The format allows you to specify different outputs depending on the value.
283 | Look at this as an associative array in which the key is separated from the value by a dot. Array elements are separated by a vertical line.
284 |
285 | ```
286 | options:search.On the search|network.In networks
287 | ```
288 |
289 | ### DateTime
290 | DateTime by default will display as Y-m-d H:i:s. Prefix the value with `datetime:` and then add your datetime format, e.g.,
291 |
292 | ```
293 | datetime:m/d/Y g:i A
294 | ```
295 |
296 | ### Is Empty
297 | This piggy backs off boolean, but instead of testing for a true or false value, it checks if the value is either null or an empty string.
298 |
299 | ```
300 | isEmpty:No|Yes
301 | ```
302 |
303 | This can also accept `%s` if you'd like to output the value, something like the following will display 'Nothing' if the value is empty, or the actual value if something exists:
304 |
305 | ```
306 | isEmpty:Nothing|%s
307 | ```
308 |
309 |
310 | ## Load revision history
311 |
312 | To load the revision history for a given model, simply call the `revisionHistory` method on that model, e.g.,
313 |
314 | ```php
315 | $article = Article::find($id);
316 | $history = $article->revisionHistory;
317 | ```
318 |
319 |
320 | ## Displaying history
321 |
322 | For the most part, the revision history will hold enough information to directly output a change history, however in the cases where a foreign key is updated we need to be able to do some mapping and display something nicer than `plan_id changed from 3 to 1`.
323 |
324 | To help with this, there's a few helper methods to display more insightful information, so you can display something like `Chris changed plan from bronze to gold`.
325 |
326 | The above would be the result from this:
327 |
328 | ```php
329 | @foreach($account->revisionHistory as $history )
330 |
{{ $history->userResponsible()->first_name }} changed {{ $history->fieldName() }} from {{ $history->oldValue() }} to {{ $history->newValue() }}
331 | @endforeach
332 | ```
333 |
334 | If you have enabled revisions of creations as well you can display it like this:
335 | ```php
336 | @foreach($resource->revisionHistory as $history)
337 | @if($history->key == 'created_at' && !$history->old_value)
338 | {{ $history->userResponsible()->first_name }} created this resource at {{ $history->newValue() }}
339 | @else
340 | {{ $history->userResponsible()->first_name }} changed {{ $history->fieldName() }} from {{ $history->oldValue() }} to {{ $history->newValue() }}
341 | @endif
342 | @endforeach
343 | ```
344 |
345 | ### userResponsible()
346 |
347 | Returns the User that was responsible for making the revision. A user model is returned, or false if there was no user recorded.
348 |
349 | The user model that is loaded depends on what you have set in your `config/auth.php` file for the `model` variable.
350 |
351 | ### fieldName()
352 |
353 | Returns the name of the field that was updated, if the field that was updated was a foreign key (at this stage, it simply looks to see if the field has the suffix of `_id`) then the text before `_id` is returned. e.g., if the field was `plan_id`, then `plan` would be returned.
354 |
355 | > Remember from above, that you can override the output of a field name with the `$revisionFormattedFieldNames` array in your model.
356 |
357 | ### identifiableName()
358 |
359 | This is used when the value (old or new) is the id of a foreign key relationship.
360 |
361 | By default, it simply returns the ID of the model that was updated. It is up to you to override this method in your own models to return something meaningful. e.g.,
362 |
363 | ```php
364 | use Venturecraft\Revisionable\Revisionable;
365 |
366 | class Article extends Revisionable
367 | {
368 | public function identifiableName()
369 | {
370 | return $this->title;
371 | }
372 | }
373 | ```
374 |
375 | ### oldValue() and newValue()
376 |
377 | Get the value of the model before or after the update. If it was a foreign key, identifiableName() is called.
378 |
379 | ### Unknown or invalid foreign keys as revisions
380 | In cases where the old or new version of a value is a foreign key that no longer exists, or indeed was null, there are two variables that you can set in your model to control the output in these situations:
381 |
382 | ```php
383 | protected $revisionNullString = 'nothing';
384 | protected $revisionUnknownString = 'unknown';
385 | ```
386 |
387 | ### disableRevisionField()
388 | Sometimes temporarily disabling a revisionable field can come in handy, if you want to be able to save an update however don't need to keep a record of the changes.
389 |
390 | ```php
391 | $object->disableRevisionField('title'); // Disables title
392 | ```
393 |
394 | or:
395 |
396 | ```php
397 | $object->disableRevisionField(['title', 'content']); // Disables title and content
398 | ```
399 |
400 |
401 | ## Contributing
402 |
403 | Contributions are encouraged and welcome; to keep things organised, all bugs and requests should be
404 | opened in the GitHub issues tab for the main project, at [venturecraft/revisionable/issues](https://github.com/venturecraft/revisionable/issues)
405 |
406 | All pull requests should be made to the develop branch, so they can be tested before being merged into the master branch.
407 |
408 |
409 | ## Having troubles?
410 |
411 | If you're having troubles with using this package, odds on someone else has already had the same problem. Two places you can look for common answers to your problems are:
412 |
413 | * [StackOverflow revisionable tag](https://stackoverflow.com/questions/tagged/revisionable?sort=newest&pageSize=50)
414 | * [GitHub Issues](https://github.com/VentureCraft/revisionable/issues)
415 |
416 | > If you do prefer posting your questions to the public on StackOverflow, please use the 'revisionable' tag.
417 |
--------------------------------------------------------------------------------
/src/Venturecraft/Revisionable/RevisionableTrait.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | */
11 |
12 | /**
13 | * Class RevisionableTrait
14 | * @package Venturecraft\Revisionable
15 | */
16 | trait RevisionableTrait
17 | {
18 | /**
19 | * @var array
20 | */
21 | private $originalData = array();
22 |
23 | /**
24 | * @var array
25 | */
26 | private $updatedData = array();
27 |
28 | /**
29 | * @var boolean
30 | */
31 | private $updating = false;
32 |
33 | /**
34 | * @var array
35 | */
36 | private $dontKeep = array();
37 |
38 | /**
39 | * @var array
40 | */
41 | private $doKeep = array();
42 |
43 | /**
44 | * Keeps the list of values that have been updated
45 | *
46 | * @var array
47 | */
48 | protected $dirtyData = array();
49 |
50 | /**
51 | * Ensure that the bootRevisionableTrait is called only
52 | * if the current installation is a laravel 4 installation
53 | * Laravel 5 will call bootRevisionableTrait() automatically
54 | */
55 | public static function boot()
56 | {
57 | parent::boot();
58 |
59 | if (!method_exists(get_called_class(), 'bootTraits')) {
60 | static::bootRevisionableTrait();
61 | }
62 | }
63 |
64 | /**
65 | * Create the event listeners for the saving and saved events
66 | * This lets us save revisions whenever a save is made, no matter the
67 | * http method.
68 | *
69 | */
70 | public static function bootRevisionableTrait()
71 | {
72 | static::saving(function ($model) {
73 | $model->preSave();
74 | });
75 |
76 | static::saved(function ($model) {
77 | $model->postSave();
78 | });
79 |
80 | static::created(function ($model) {
81 | $model->postCreate();
82 | });
83 |
84 | static::deleted(function ($model) {
85 | $model->preSave();
86 | $model->postDelete();
87 | $model->postForceDelete();
88 | });
89 | }
90 |
91 | /**
92 | * @return mixed
93 | */
94 | public function revisionHistory()
95 | {
96 | return $this->morphMany(get_class(Revisionable::newModel()), 'revisionable');
97 | }
98 |
99 | /**
100 | * Generates a list of the last $limit revisions made to any objects of the class it is being called from.
101 | *
102 | * @param int $limit
103 | * @param string $order
104 | * @return mixed
105 | */
106 | public static function classRevisionHistory($limit = 100, $order = 'desc')
107 | {
108 | $model = Revisionable::newModel();
109 | return $model->where('revisionable_type', get_called_class())
110 | ->orderBy('updated_at', $order)->limit($limit)->get();
111 | }
112 |
113 | /**
114 | * Invoked before a model is saved. Return false to abort the operation.
115 | *
116 | * @return bool
117 | */
118 | public function preSave()
119 | {
120 | if (!isset($this->revisionEnabled) || $this->revisionEnabled) {
121 | // if there's no revisionEnabled. Or if there is, if it's true
122 |
123 | $this->originalData = $this->original;
124 | $this->updatedData = $this->attributes;
125 |
126 | // we can only safely compare basic items,
127 | // so for now we drop any object based items, like DateTime
128 | foreach ($this->updatedData as $key => $val) {
129 | $castCheck = ['object', 'array'];
130 | if (isset($this->casts[$key]) && in_array(gettype($val), $castCheck) && in_array($this->casts[$key], $castCheck) && isset($this->originalData[$key])) {
131 | // Sorts the keys of a JSON object due Normalization performed by MySQL
132 | // So it doesn't set false flag if it is changed only order of key or whitespace after comma
133 |
134 | $updatedData = $this->sortJsonKeys(json_decode($this->updatedData[$key], true));
135 |
136 | $this->updatedData[$key] = json_encode($updatedData);
137 | $this->originalData[$key] = json_encode(json_decode($this->originalData[$key], true));
138 | } else if (gettype($val) == 'object' && !method_exists($val, '__toString')) {
139 | unset($this->originalData[$key]);
140 | unset($this->updatedData[$key]);
141 | array_push($this->dontKeep, $key);
142 | }
143 | }
144 |
145 | // the below is ugly, for sure, but it's required so we can save the standard model
146 | // then use the keep / dontkeep values for later, in the isRevisionable method
147 | $this->dontKeep = isset($this->dontKeepRevisionOf) ?
148 | array_merge($this->dontKeepRevisionOf, $this->dontKeep)
149 | : $this->dontKeep;
150 |
151 | $this->doKeep = isset($this->keepRevisionOf) ?
152 | array_merge($this->keepRevisionOf, $this->doKeep)
153 | : $this->doKeep;
154 |
155 | unset($this->attributes['dontKeepRevisionOf']);
156 | unset($this->attributes['keepRevisionOf']);
157 |
158 | $this->dirtyData = $this->getDirty();
159 | $this->updating = $this->exists;
160 | }
161 | }
162 |
163 |
164 | /**
165 | * Called after a model is successfully saved.
166 | *
167 | * @return void
168 | */
169 | public function postSave()
170 | {
171 | if (isset($this->historyLimit) && $this->revisionHistory()->count() >= $this->historyLimit) {
172 | $LimitReached = true;
173 | } else {
174 | $LimitReached = false;
175 | }
176 | if (isset($this->revisionCleanup)){
177 | $RevisionCleanup=$this->revisionCleanup;
178 | }else{
179 | $RevisionCleanup=false;
180 | }
181 |
182 | // check if the model already exists
183 | if (((!isset($this->revisionEnabled) || $this->revisionEnabled) && $this->updating) && (!$LimitReached || $RevisionCleanup)) {
184 | // if it does, it means we're updating
185 |
186 | $changes_to_record = $this->changedRevisionableFields();
187 |
188 | $revisions = array();
189 |
190 | foreach ($changes_to_record as $key => $change) {
191 | $original = array(
192 | 'revisionable_type' => $this->getMorphClass(),
193 | 'revisionable_id' => $this->getKey(),
194 | 'key' => $key,
195 | 'old_value' => Arr::get($this->originalData, $key),
196 | 'new_value' => $this->updatedData[$key],
197 | 'user_id' => $this->getSystemUserId(),
198 | 'created_at' => new \DateTime(),
199 | 'updated_at' => new \DateTime(),
200 | );
201 |
202 | $revisions[] = array_merge($original, $this->getAdditionalFields());
203 | }
204 |
205 | if (count($revisions) > 0) {
206 | if($LimitReached && $RevisionCleanup){
207 | $toDelete = $this->revisionHistory()->orderBy('id','asc')->limit(count($revisions))->get();
208 | foreach($toDelete as $delete){
209 | $delete->delete();
210 | }
211 | }
212 | $revision = Revisionable::newModel();
213 | \DB::table($revision->getTable())->insert($revisions);
214 | \Event::dispatch('revisionable.saved', array('model' => $this, 'revisions' => $revisions));
215 | }
216 | }
217 | }
218 |
219 | /**
220 | * Called after record successfully created
221 | */
222 | public function postCreate()
223 | {
224 |
225 | // Check if we should store creations in our revision history
226 | // Set this value to true in your model if you want to
227 | if(empty($this->revisionCreationsEnabled))
228 | {
229 | // We should not store creations.
230 | return false;
231 | }
232 |
233 | if ((!isset($this->revisionEnabled) || $this->revisionEnabled))
234 | {
235 | $revisions[] = array(
236 | 'revisionable_type' => $this->getMorphClass(),
237 | 'revisionable_id' => $this->getKey(),
238 | 'key' => self::CREATED_AT,
239 | 'old_value' => null,
240 | 'new_value' => $this->{self::CREATED_AT},
241 | 'user_id' => $this->getSystemUserId(),
242 | 'created_at' => new \DateTime(),
243 | 'updated_at' => new \DateTime(),
244 | );
245 |
246 | //Determine if there are any additional fields we'd like to add to our model contained in the config file, and
247 | //get them into an array.
248 | $revisions = array_merge($revisions[0], $this->getAdditionalFields());
249 |
250 | $revision = Revisionable::newModel();
251 | \DB::table($revision->getTable())->insert($revisions);
252 | \Event::dispatch('revisionable.created', array('model' => $this, 'revisions' => $revisions));
253 | }
254 |
255 | }
256 |
257 | /**
258 | * If softdeletes are enabled, store the deleted time
259 | */
260 | public function postDelete()
261 | {
262 | if ((!isset($this->revisionEnabled) || $this->revisionEnabled)
263 | && $this->isSoftDelete()
264 | && $this->isRevisionable($this->getDeletedAtColumn())
265 | ) {
266 | $revisions[] = array(
267 | 'revisionable_type' => $this->getMorphClass(),
268 | 'revisionable_id' => $this->getKey(),
269 | 'key' => $this->getDeletedAtColumn(),
270 | 'old_value' => null,
271 | 'new_value' => $this->{$this->getDeletedAtColumn()},
272 | 'user_id' => $this->getSystemUserId(),
273 | 'created_at' => new \DateTime(),
274 | 'updated_at' => new \DateTime(),
275 | );
276 |
277 | //Since there is only one revision because it's deleted, let's just merge into revision[0]
278 | $revisions = array_merge($revisions[0], $this->getAdditionalFields());
279 |
280 | $revision = Revisionable::newModel();
281 | \DB::table($revision->getTable())->insert($revisions);
282 | \Event::dispatch('revisionable.deleted', array('model' => $this, 'revisions' => $revisions));
283 | }
284 | }
285 |
286 | /**
287 | * If forcedeletes are enabled, set the value created_at of model to null
288 | *
289 | * @return void|bool
290 | */
291 | public function postForceDelete()
292 | {
293 | if (empty($this->revisionForceDeleteEnabled)) {
294 | return false;
295 | }
296 |
297 | if ((!isset($this->revisionEnabled) || $this->revisionEnabled)
298 | && (($this->isSoftDelete() && $this->isForceDeleting()) || !$this->isSoftDelete())) {
299 |
300 | $revisions[] = array(
301 | 'revisionable_type' => $this->getMorphClass(),
302 | 'revisionable_id' => $this->getKey(),
303 | 'key' => self::CREATED_AT,
304 | 'old_value' => $this->{self::CREATED_AT},
305 | 'new_value' => null,
306 | 'user_id' => $this->getSystemUserId(),
307 | 'created_at' => new \DateTime(),
308 | 'updated_at' => new \DateTime(),
309 | );
310 |
311 | $revision = Revisionable::newModel();
312 | \DB::table($revision->getTable())->insert($revisions);
313 | \Event::dispatch('revisionable.deleted', array('model' => $this, 'revisions' => $revisions));
314 | }
315 | }
316 |
317 | /**
318 | * Attempt to find the user id of the currently logged in user
319 | * Supports Cartalyst Sentry/Sentinel based authentication, as well as stock Auth
320 | **/
321 | public function getSystemUserId()
322 | {
323 | try {
324 | if (class_exists($class = '\SleepingOwl\AdminAuth\Facades\AdminAuth')
325 | || class_exists($class = '\Cartalyst\Sentry\Facades\Laravel\Sentry')
326 | || class_exists($class = '\Cartalyst\Sentinel\Laravel\Facades\Sentinel')
327 | ) {
328 | return ($class::check()) ? $class::getUser()->id : null;
329 | } elseif (function_exists('backpack_auth') && backpack_auth()->check()) {
330 | return backpack_user()->id;
331 | } elseif (\Auth::check()) {
332 | return \Auth::user()->getAuthIdentifier();
333 | }
334 | } catch (\Exception $e) {
335 | return null;
336 | }
337 |
338 | return null;
339 | }
340 |
341 |
342 | public function getAdditionalFields()
343 | {
344 | $additional = [];
345 | //Determine if there are any additional fields we'd like to add to our model contained in the config file, and
346 | //get them into an array.
347 | $fields = config('revisionable.additional_fields', []);
348 | foreach($fields as $field) {
349 | if(Arr::has($this->originalData, $field)) {
350 | $additional[$field] = Arr::get($this->originalData, $field);
351 | }
352 | }
353 |
354 | return $additional;
355 | }
356 |
357 | /**
358 | * Get all of the changes that have been made, that are also supposed
359 | * to have their changes recorded
360 | *
361 | * @return array fields with new data, that should be recorded
362 | */
363 | private function changedRevisionableFields()
364 | {
365 | $changes_to_record = array();
366 | foreach ($this->dirtyData as $key => $value) {
367 | // check that the field is revisionable, and double check
368 | // that it's actually new data in case dirty is, well, clean
369 | if ($this->isRevisionable($key) && !is_array($value)) {
370 | if (!array_key_exists($key, $this->originalData) || $this->originalData[$key] != $this->updatedData[$key]) {
371 | $changes_to_record[$key] = $value;
372 | }
373 | } else {
374 | // we don't need these any more, and they could
375 | // contain a lot of data, so lets trash them.
376 | unset($this->updatedData[$key]);
377 | unset($this->originalData[$key]);
378 | }
379 | }
380 |
381 | return $changes_to_record;
382 | }
383 |
384 | /**
385 | * Check if this field should have a revision kept
386 | *
387 | * @param string $key
388 | *
389 | * @return bool
390 | */
391 | private function isRevisionable($key)
392 | {
393 |
394 | // If the field is explicitly revisionable, then return true.
395 | // If it's explicitly not revisionable, return false.
396 | // Otherwise, if neither condition is met, only return true if
397 | // we aren't specifying revisionable fields.
398 | if (isset($this->doKeep) && in_array($key, $this->doKeep)) {
399 | return true;
400 | }
401 | if (isset($this->dontKeep) && in_array($key, $this->dontKeep)) {
402 | return false;
403 | }
404 |
405 | return empty($this->doKeep);
406 | }
407 |
408 | /**
409 | * Check if soft deletes are currently enabled on this model
410 | *
411 | * @return bool
412 | */
413 | private function isSoftDelete()
414 | {
415 | // check flag variable used in laravel 4.2+
416 | if (isset($this->forceDeleting)) {
417 | return !$this->forceDeleting;
418 | }
419 |
420 | // otherwise, look for flag used in older versions
421 | if (isset($this->softDelete)) {
422 | return $this->softDelete;
423 | }
424 |
425 | return false;
426 | }
427 |
428 | /**
429 | * @return mixed
430 | */
431 | public function getRevisionFormattedFields()
432 | {
433 | return $this->revisionFormattedFields;
434 | }
435 |
436 | /**
437 | * @return mixed
438 | */
439 | public function getRevisionFormattedFieldNames()
440 | {
441 | return $this->revisionFormattedFieldNames;
442 | }
443 |
444 | /**
445 | * Identifiable Name
446 | * When displaying revision history, when a foreign key is updated
447 | * instead of displaying the ID, you can choose to display a string
448 | * of your choice, just override this method in your model
449 | * By default, it will fall back to the models ID.
450 | *
451 | * @return string an identifying name for the model
452 | */
453 | public function identifiableName()
454 | {
455 | return $this->getKey();
456 | }
457 |
458 | /**
459 | * Revision Unknown String
460 | * When displaying revision history, when a foreign key is updated
461 | * instead of displaying the ID, you can choose to display a string
462 | * of your choice, just override this method in your model
463 | * By default, it will fall back to the models ID.
464 | *
465 | * @return string an identifying name for the model
466 | */
467 | public function getRevisionNullString()
468 | {
469 | return isset($this->revisionNullString) ? $this->revisionNullString : 'nothing';
470 | }
471 |
472 | /**
473 | * No revision string
474 | * When displaying revision history, if the revisions value
475 | * cant be figured out, this is used instead.
476 | * It can be overridden.
477 | *
478 | * @return string an identifying name for the model
479 | */
480 | public function getRevisionUnknownString()
481 | {
482 | return isset($this->revisionUnknownString) ? $this->revisionUnknownString : 'unknown';
483 | }
484 |
485 | /**
486 | * Disable a revisionable field temporarily
487 | * Need to do the adding to array longhanded, as there's a
488 | * PHP bug https://bugs.php.net/bug.php?id=42030
489 | *
490 | * @param mixed $field
491 | *
492 | * @return void
493 | */
494 | public function disableRevisionField($field)
495 | {
496 | if (!isset($this->dontKeepRevisionOf)) {
497 | $this->dontKeepRevisionOf = array();
498 | }
499 | if (is_array($field)) {
500 | foreach ($field as $one_field) {
501 | $this->disableRevisionField($one_field);
502 | }
503 | } else {
504 | $donts = $this->dontKeepRevisionOf;
505 | $donts[] = $field;
506 | $this->dontKeepRevisionOf = $donts;
507 | unset($donts);
508 | }
509 | }
510 |
511 | /**
512 | * Sorts the keys of a JSON object
513 | *
514 | * Normalization performed by MySQL and
515 | * discards extra whitespace between keys, values, or elements
516 | * in the original JSON document.
517 | * To make lookups more efficient, it sorts the keys of a JSON object.
518 | *
519 | * @param mixed $attribute
520 | *
521 | * @return mixed
522 | */
523 | private function sortJsonKeys($attribute)
524 | {
525 | if(empty($attribute)) return $attribute;
526 |
527 | foreach ($attribute as $key=>$value) {
528 | if(is_array($value) || is_object($value)){
529 | $value = $this->sortJsonKeys($value);
530 | } else {
531 | continue;
532 | }
533 |
534 | ksort($value);
535 | $attribute[$key] = $value;
536 | }
537 |
538 | return $attribute;
539 | }
540 | }
541 |
--------------------------------------------------------------------------------