Test Failed
Pull Request — master (#684)
by Morten
05:37
created

Auditable::getAttributeModifiers()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 1
eloc 1
c 1
b 0
f 1
nc 1
nop 0
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
1
<?php
2
3
namespace OwenIt\Auditing;
4
5
use Illuminate\Database\Eloquent\Relations\MorphMany;
6
use Illuminate\Database\Eloquent\SoftDeletes;
7
use Illuminate\Support\Arr;
8
use Illuminate\Support\Facades\App;
9
use Illuminate\Support\Facades\Config;
10
use OwenIt\Auditing\Contracts\AttributeEncoder;
11
use OwenIt\Auditing\Contracts\AttributeRedactor;
12
use OwenIt\Auditing\Contracts\Resolver;
13
use OwenIt\Auditing\Exceptions\AuditableTransitionException;
14
use OwenIt\Auditing\Exceptions\AuditingException;
15
16
trait Auditable
17
{
18
    /**
19
     * Auditable attributes excluded from the Audit.
20
     *
21
     * @var array
22
     */
23
    protected $excludedAttributes = [];
24
25
    /**
26
     * Audit event name.
27
     *
28
     * @var string
29
     */
30
    protected $auditEvent;
31
32
    /**
33
     * Is auditing disabled?
34
     *
35
     * @var bool
36
     */
37
    public static $auditingDisabled = false;
38
39
    /**
40
     * @var array
41
     */
42
    protected $auditExclude;
43
44
    /**
45
     * Property may set custom event data to register
46
     * @var null|array
47
     */
48
    public $auditCustomOld = null;
49
50
    /**
51
     * Property may set custom event data to register
52
     * @var null|array
53
     */
54
    public $auditCustomNew = null;
55
56
    /**
57
     * If this is a custom event (as opposed to an eloquent event
58
     * @var bool
59
     */
60
    public $isCustomEvent = false;
61
62
    /**
63
     * Auditable boot logic.
64
     *
65
     * @return void
66
     */
67 14
    public static function bootAuditable()
68
    {
69 14
        if (!self::$auditingDisabled && static::isAuditingEnabled()) {
70 12
            static::observe(new AuditableObserver());
71
        }
72 14
    }
73
74
    /**
75
     * {@inheritdoc}
76
     */
77
    public function audits(): MorphMany
78
    {
79
        return $this->morphMany(
0 ignored issues
show
Bug introduced by
It seems like morphMany() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

79
        return $this->/** @scrutinizer ignore-call */ morphMany(
Loading history...
80
            Config::get('audit.implementation', Models\Audit::class),
81
            'auditable'
82
        );
83
    }
84
85
    /**
86
     * Resolve the Auditable attributes to exclude from the Audit.
87
     *
88
     * @return void
89
     */
90 12
    protected function resolveAuditExclusions()
91
    {
92 12
        $this->excludedAttributes = $this->getAuditExclude();
93
94
        // When in strict mode, hidden and non visible attributes are excluded
95 12
        if ($this->getAuditStrict()) {
96
            // Hidden attributes
97
            $this->excludedAttributes = array_merge($this->excludedAttributes, $this->hidden);
98
99
            // Non visible attributes
100
            if ($this->visible) {
101
                $invisible = array_diff(array_keys($this->attributes), $this->visible);
102
103
                $this->excludedAttributes = array_merge($this->excludedAttributes, $invisible);
104
            }
105
        }
106
107
        // Exclude Timestamps
108 12
        if (!$this->getAuditTimestamps()) {
109 12
            array_push($this->excludedAttributes, $this->getCreatedAtColumn(), $this->getUpdatedAtColumn());
0 ignored issues
show
Bug introduced by
It seems like getUpdatedAtColumn() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

109
            array_push($this->excludedAttributes, $this->getCreatedAtColumn(), $this->/** @scrutinizer ignore-call */ getUpdatedAtColumn());
Loading history...
Bug introduced by
It seems like getCreatedAtColumn() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

109
            array_push($this->excludedAttributes, $this->/** @scrutinizer ignore-call */ getCreatedAtColumn(), $this->getUpdatedAtColumn());
Loading history...
110
111 12
            if (in_array(SoftDeletes::class, class_uses_recursive(get_class($this)))) {
112 4
                $this->excludedAttributes[] = $this->getDeletedAtColumn();
0 ignored issues
show
Bug introduced by
It seems like getDeletedAtColumn() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

112
                /** @scrutinizer ignore-call */ 
113
                $this->excludedAttributes[] = $this->getDeletedAtColumn();
Loading history...
113
            }
114
        }
115
116
        // Valid attributes are all those that made it out of the exclusion array
117 12
        $attributes = Arr::except($this->attributes, $this->excludedAttributes);
118
119 12
        foreach ($attributes as $attribute => $value) {
120
            // Apart from null, non scalar values will be excluded
121 12
            if (is_array($value) || (is_object($value) && !method_exists($value, '__toString'))) {
122
                $this->excludedAttributes[] = $attribute;
123
            }
124
        }
125 12
    }
126
127
    /**
128
     * Get the old/new attributes of a retrieved event.
129
     *
130
     * @return array
131
     */
132 4
    protected function getRetrievedEventAttributes(): array
133
    {
134
        // This is a read event with no attribute changes,
135
        // only metadata will be stored in the Audit
136
137
        return [
138 4
            [],
139
            [],
140
        ];
141
    }
142
143
    /**
144
     * Get the old/new attributes of a created event.
145
     *
146
     * @return array
147
     */
148 10
    protected function getCreatedEventAttributes(): array
149
    {
150 10
        $new = [];
151
152 10
        foreach ($this->attributes as $attribute => $value) {
153 10
            if ($this->isAttributeAuditable($attribute)) {
154 10
                $new[$attribute] = $value;
155
            }
156
        }
157
158
        return [
159 10
            [],
160 10
            $new,
161
        ];
162
    }
163
164
    protected function getCustomEventAttributes(): array
165
    {
166
        return [
167
            $this->auditCustomOld,
168
            $this->auditCustomNew
169
        ];
170
    }
171
172
    /**
173
     * Get the old/new attributes of an updated event.
174
     *
175
     * @return array
176
     */
177
    protected function getUpdatedEventAttributes(): array
178
    {
179
        $old = [];
180
        $new = [];
181
182
        foreach ($this->getDirty() as $attribute => $value) {
0 ignored issues
show
Bug introduced by
It seems like getDirty() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

182
        foreach ($this->/** @scrutinizer ignore-call */ getDirty() as $attribute => $value) {
Loading history...
183
            if ($this->isAttributeAuditable($attribute)) {
184
                $old[$attribute] = Arr::get($this->original, $attribute);
185
                $new[$attribute] = Arr::get($this->attributes, $attribute);
186
            }
187
        }
188
189
        return [
190
            $old,
191
            $new,
192
        ];
193
    }
194
195
    /**
196
     * Get the old/new attributes of a deleted event.
197
     *
198
     * @return array
199
     */
200
    protected function getDeletedEventAttributes(): array
201
    {
202
        $old = [];
203
204
        foreach ($this->attributes as $attribute => $value) {
205
            if ($this->isAttributeAuditable($attribute)) {
206
                $old[$attribute] = $value;
207
            }
208
        }
209
210
        return [
211
            $old,
212
            [],
213
        ];
214
    }
215
216
    /**
217
     * Get the old/new attributes of a restored event.
218
     *
219
     * @return array
220
     */
221
    protected function getRestoredEventAttributes(): array
222
    {
223
        // A restored event is just a deleted event in reverse
224
        return array_reverse($this->getDeletedEventAttributes());
225
    }
226
227
    /**
228
     * {@inheritdoc}
229
     */
230 12
    public function readyForAuditing(): bool
231
    {
232 12
        if (static::$auditingDisabled) {
233
            return false;
234
        }
235
236 12
        if ($this->isCustomEvent) {
237
            return true;
238
        }
239
240 12
        return $this->isEventAuditable($this->auditEvent);
241
    }
242
243
    /**
244
     * Modify attribute value.
245
     *
246
     * @param string $attribute
247
     * @param mixed $value
248
     *
249
     * @return mixed
250
     * @throws AuditingException
251
     *
252
     */
253
    protected function modifyAttributeValue(string $attribute, $value)
254
    {
255
        $attributeModifiers = $this->getAttributeModifiers();
256
257
        if (!array_key_exists($attribute, $attributeModifiers)) {
258
            return $value;
259
        }
260
261
        $attributeModifier = $attributeModifiers[$attribute];
262
263
        if (is_subclass_of($attributeModifier, AttributeRedactor::class)) {
264
            return call_user_func([$attributeModifier, 'redact'], $value);
265
        }
266
267
        if (is_subclass_of($attributeModifier, AttributeEncoder::class)) {
268
            return call_user_func([$attributeModifier, 'encode'], $value);
269
        }
270
271
        throw new AuditingException(sprintf('Invalid AttributeModifier implementation: %s', $attributeModifier));
272
    }
273
274
    /**
275
     * {@inheritdoc}
276
     */
277 12
    public function toAudit(): array
278
    {
279 12
        if (!$this->readyForAuditing()) {
280
            throw new AuditingException('A valid audit event has not been set');
281
        }
282
283 12
        $attributeGetter = $this->resolveAttributeGetter($this->auditEvent);
284
285 12
        if (!method_exists($this, $attributeGetter)) {
0 ignored issues
show
Bug introduced by
It seems like $attributeGetter can also be of type null; however, parameter $method of method_exists() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

285
        if (!method_exists($this, /** @scrutinizer ignore-type */ $attributeGetter)) {
Loading history...
286
            throw new AuditingException(sprintf(
287
                'Unable to handle "%s" event, %s() method missing',
288
                $this->auditEvent,
289
                $attributeGetter
290
            ));
291
        }
292
293 12
        $this->resolveAuditExclusions();
294
295 12
        list($old, $new) = $this->$attributeGetter();
296
297 12
        if ($this->getAttributeModifiers() && !$this->isCustomEvent) {
298
            foreach ($old as $attribute => $value) {
299
                $old[$attribute] = $this->modifyAttributeValue($attribute, $value);
300
            }
301
302
            foreach ($new as $attribute => $value) {
303
                $new[$attribute] = $this->modifyAttributeValue($attribute, $value);
304
            }
305
        }
306
307 12
        $morphPrefix = Config::get('audit.user.morph_prefix', 'user');
308
309 12
        $tags = implode(',', $this->generateTags());
310
311 12
        $user = $this->resolveUser();
312
313 12
        return $this->transformAudit(array_merge([
314 12
            'old_values'           => $old,
315 12
            'new_values'           => $new,
316 12
            'event'                => $this->auditEvent,
317 12
            'auditable_id'         => $this->getKey(),
0 ignored issues
show
Bug introduced by
It seems like getKey() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

317
            'auditable_id'         => $this->/** @scrutinizer ignore-call */ getKey(),
Loading history...
318 12
            'auditable_type'       => $this->getMorphClass(),
0 ignored issues
show
Bug introduced by
It seems like getMorphClass() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

318
            'auditable_type'       => $this->/** @scrutinizer ignore-call */ getMorphClass(),
Loading history...
319 12
            $morphPrefix . '_id'   => $user ? $user->getAuthIdentifier() : null,
320 12
            $morphPrefix . '_type' => $user ? $user->getMorphClass() : null,
321 12
            'tags'                 => empty($tags) ? null : $tags,
322 12
        ], $this->runResolvers()));
323
    }
324
325
    /**
326
     * {@inheritdoc}
327
     */
328 12
    public function transformAudit(array $data): array
329
    {
330 12
        return $data;
331
    }
332
333
    /**
334
     * Resolve the User.
335
     *
336
     * @return mixed|null
337
     * @throws AuditingException
338
     *
339
     */
340 12
    protected function resolveUser()
341
    {
342 12
        $userResolver = Config::get('audit.user.resolver');
343
344 12
        if (is_subclass_of($userResolver, \OwenIt\Auditing\Contracts\UserResolver::class)) {
345 12
            return call_user_func([$userResolver, 'resolve']);
346
        }
347
348
        throw new AuditingException('Invalid UserResolver implementation');
349
    }
350
351 12
    protected function runResolvers(): array
352
    {
353 12
        $resolved = [];
354 12
        foreach (Config::get('audit.resolvers', []) as $name => $implementation) {
355 12
            if (empty($implementation)) {
356
                continue;
357
            }
358
359 12
            if (!is_subclass_of($implementation, Resolver::class)) {
360
                throw new AuditingException('Invalid Resolver implementation for: ' . $name);
361
            }
362 12
            $resolved[$name] = call_user_func([$implementation, 'resolve'], $this);
363
        }
364 12
        return $resolved;
365
    }
366
367
    /**
368
     * Determine if an attribute is eligible for auditing.
369
     *
370
     * @param string $attribute
371
     *
372
     * @return bool
373
     */
374 10
    protected function isAttributeAuditable(string $attribute): bool
375
    {
376
        // The attribute should not be audited
377 10
        if (in_array($attribute, $this->excludedAttributes, true)) {
378 10
            return false;
379
        }
380
381
        // The attribute is auditable when explicitly
382
        // listed or when the include array is empty
383 10
        $include = $this->getAuditInclude();
384
385 10
        return empty($include) || in_array($attribute, $include, true);
386
    }
387
388
    /**
389
     * Determine whether an event is auditable.
390
     *
391
     * @param string $event
392
     *
393
     * @return bool
394
     */
395 12
    protected function isEventAuditable($event): bool
396
    {
397 12
        return is_string($this->resolveAttributeGetter($event));
398
    }
399
400
    /**
401
     * Attribute getter method resolver.
402
     *
403
     * @param string $event
404
     *
405
     * @return string|null
406
     */
407 12
    protected function resolveAttributeGetter($event)
408
    {
409 12
        if (empty($event)) {
410 4
            return;
411
        }
412
413 12
        if ($this->isCustomEvent) {
414
            return 'getCustomEventAttributes';
415
        }
416
417 12
        foreach ($this->getAuditEvents() as $key => $value) {
418 12
            $auditableEvent = is_int($key) ? $value : $key;
419
420 12
            $auditableEventRegex = sprintf('/%s/', preg_replace('/\*+/', '.*', $auditableEvent));
421
422 12
            if (preg_match($auditableEventRegex, $event)) {
423 12
                return is_int($key) ? sprintf('get%sEventAttributes', ucfirst($event)) : $value;
424
            }
425
        }
426 4
    }
427
428
    /**
429
     * {@inheritdoc}
430
     */
431 12
    public function setAuditEvent(string $event): Contracts\Auditable
432
    {
433 12
        $this->auditEvent = $this->isEventAuditable($event) ? $event : null;
434
435 12
        return $this;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this returns the type OwenIt\Auditing\Auditable which is incompatible with the type-hinted return OwenIt\Auditing\Contracts\Auditable.
Loading history...
436
    }
437
438
    public function setAuditExcludedAttributes(array $excludedAttributes)
439
    {
440
        $this->auditExclude = $excludedAttributes;
441
    }
442
443
    /**
444
     * {@inheritdoc}
445
     */
446
    public function getAuditEvent()
447
    {
448
        return $this->auditEvent;
449
    }
450
451
    /**
452
     * {@inheritdoc}
453
     */
454 12
    public function getAuditEvents(): array
455
    {
456 12
        return $this->auditEvents ?? Config::get('audit.events', [
0 ignored issues
show
Bug introduced by
The property auditEvents does not exist on OwenIt\Auditing\Auditable. Did you mean auditEvent?
Loading history...
457 12
                'created',
458
                'updated',
459
                'deleted',
460
                'restored',
461
            ]);
462
    }
463
464
    /**
465
     * Disable Auditing.
466
     *
467
     * @return void
468
     */
469
    public static function disableAuditing()
470
    {
471
        static::$auditingDisabled = true;
472
    }
473
474
    /**
475
     * Enable Auditing.
476
     *
477
     * @return void
478
     */
479
    public static function enableAuditing()
480
    {
481
        static::$auditingDisabled = false;
482
    }
483
484
    /**
485
     * Determine whether auditing is enabled.
486
     *
487
     * @return bool
488
     */
489 14
    public static function isAuditingEnabled(): bool
490
    {
491 14
        if (App::runningInConsole()) {
492 12
            return Config::get('audit.enabled', true) && Config::get('audit.console', false);
493
        }
494
495 2
        return Config::get('audit.enabled', true);
496
    }
497
498
    /**
499
     * {@inheritdoc}
500
     */
501 10
    public function getAuditInclude(): array
502
    {
503 10
        return $this->auditInclude ?? [];
0 ignored issues
show
Bug introduced by
The property auditInclude does not exist on OwenIt\Auditing\Auditable. Did you mean auditExclude?
Loading history...
504
    }
505
506
    /**
507
     * {@inheritdoc}
508
     */
509 12
    public function getAuditExclude(): array
510
    {
511 12
        return $this->auditExclude ?? [];
512
    }
513
514
    /**
515
     * {@inheritdoc}
516
     */
517 12
    public function getAuditStrict(): bool
518
    {
519 12
        return $this->auditStrict ?? Config::get('audit.strict', false);
520
    }
521
522
    /**
523
     * {@inheritdoc}
524
     */
525 12
    public function getAuditTimestamps(): bool
526
    {
527 12
        return $this->auditTimestamps ?? Config::get('audit.timestamps', false);
528
    }
529
530
    /**
531
     * {@inheritdoc}
532
     */
533 12
    public function getAuditDriver()
534
    {
535 12
        return $this->auditDriver ?? Config::get('audit.driver', 'database');
536
    }
537
538
    /**
539
     * {@inheritdoc}
540
     */
541 12
    public function getAuditThreshold(): int
542
    {
543 12
        return $this->auditThreshold ?? Config::get('audit.threshold', 0);
544
    }
545
546
    /**
547
     * {@inheritdoc}
548
     */
549 12
    public function getAttributeModifiers(): array
550
    {
551 12
        return $this->attributeModifiers ?? [];
552
    }
553
554
    /**
555
     * {@inheritdoc}
556
     */
557 12
    public function generateTags(): array
558
    {
559 12
        return [];
560
    }
561
562
    /**
563
     * {@inheritdoc}
564
     */
565
    public function transitionTo(Contracts\Audit $audit, bool $old = false): Contracts\Auditable
566
    {
567
        // The Audit must be for an Auditable model of this type
568
        if ($this->getMorphClass() !== $audit->auditable_type) {
0 ignored issues
show
Bug introduced by
Accessing auditable_type on the interface OwenIt\Auditing\Contracts\Audit suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
569
            throw new AuditableTransitionException(sprintf(
570
                'Expected Auditable type %s, got %s instead',
571
                $this->getMorphClass(),
572
                $audit->auditable_type
573
            ));
574
        }
575
576
        // The Audit must be for this specific Auditable model
577
        if ($this->getKey() !== $audit->auditable_id) {
0 ignored issues
show
Bug introduced by
Accessing auditable_id on the interface OwenIt\Auditing\Contracts\Audit suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
578
            throw new AuditableTransitionException(sprintf(
579
                'Expected Auditable id %s, got %s instead',
580
                $this->getKey(),
581
                $audit->auditable_id
582
            ));
583
        }
584
585
        // Redacted data should not be used when transitioning states
586
        foreach ($this->getAttributeModifiers() as $attribute => $modifier) {
587
            if (is_subclass_of($modifier, AttributeRedactor::class)) {
588
                throw new AuditableTransitionException('Cannot transition states when an AttributeRedactor is set');
589
            }
590
        }
591
592
        // The attribute compatibility between the Audit and the Auditable model must be met
593
        $modified = $audit->getModified();
594
595
        if ($incompatibilities = array_diff_key($modified, $this->getAttributes())) {
0 ignored issues
show
Bug introduced by
The method getAttributes() does not exist on OwenIt\Auditing\Auditable. Did you maybe mean getAttributeModifiers()? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

595
        if ($incompatibilities = array_diff_key($modified, $this->/** @scrutinizer ignore-call */ getAttributes())) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
Bug introduced by
It seems like $modified can also be of type string; however, parameter $array1 of array_diff_key() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

595
        if ($incompatibilities = array_diff_key(/** @scrutinizer ignore-type */ $modified, $this->getAttributes())) {
Loading history...
596
            throw new AuditableTransitionException(sprintf(
597
                'Incompatibility between [%s:%s] and [%s:%s]',
598
                $this->getMorphClass(),
599
                $this->getKey(),
600
                get_class($audit),
601
                $audit->getKey()
0 ignored issues
show
Bug introduced by
The method getKey() does not exist on OwenIt\Auditing\Contracts\Audit. Since it exists in all sub-types, consider adding an abstract or default implementation to OwenIt\Auditing\Contracts\Audit. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

601
                $audit->/** @scrutinizer ignore-call */ 
602
                        getKey()
Loading history...
602
            ), array_keys($incompatibilities));
603
        }
604
605
        $key = $old ? 'old' : 'new';
606
607
        foreach ($modified as $attribute => $value) {
608
            if (array_key_exists($key, $value)) {
609
                $this->setAttribute($attribute, $value[$key]);
0 ignored issues
show
Bug introduced by
It seems like setAttribute() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

609
                $this->/** @scrutinizer ignore-call */ 
610
                       setAttribute($attribute, $value[$key]);
Loading history...
610
            }
611
        }
612
613
        return $this;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this returns the type OwenIt\Auditing\Auditable which is incompatible with the type-hinted return OwenIt\Auditing\Contracts\Auditable.
Loading history...
614
    }
615
}
616