Passed
Push — master ( 27c83c...3b806d )
by Loban
03:03
created

ActiveLogBehavior::deleteEntity()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 3
c 0
b 0
f 0
dl 0
loc 5
ccs 4
cts 4
cp 1
rs 10
cc 1
nc 1
nop 0
crap 1
1
<?php
2
/**
3
 * @link https://github.com/lav45/yii2-activity-logger
4
 * @copyright Copyright (c) 2017 LAV45
5
 * @author Aleksey Loban <[email protected]>
6
 * @license http://opensource.org/licenses/BSD-3-Clause
7
 */
8
9
namespace lav45\activityLogger;
10
11
use lav45\activityLogger\storage\DeleteCommand;
12
use yii\base\Behavior;
13
use yii\base\InvalidConfigException;
14
use yii\base\InvalidValueException;
15
use yii\db\ActiveRecord;
16
use yii\helpers\ArrayHelper;
17
use yii\helpers\Inflector;
18
use yii\helpers\StringHelper;
19
20
/**
21
 * ======================= Example usage ======================
22
 *
23
 *  // Recommended
24
 *  public function transactions()
25
 *  {
26
 *      return [
27
 *          ActiveRecord::SCENARIO_DEFAULT => ActiveRecord::OP_ALL,
28
 *      ];
29
 *  }
30
 *
31
 *  public function behaviors()
32
 *  {
33
 *      return [
34
 *          [
35
 *              '__class' => 'lav45\activityLogger\ActiveLogBehavior',
36
 *              'attributes' => [
37
 *                  // simple attribute
38
 *                  'title',
39
 *
40
 *                  // the value of the attribute is a item in the list
41
 *                  'status' => [
42
 *                      // => $this->getStatusList()
43
 *                      'list' => 'statusList'
44
 *                  ],
45
 *
46
 *                  // the attribute value is the [id] of the relation model
47
 *                  'owner_id' => [
48
 *                      'relation' => 'owner',
49
 *                      'attribute' => 'username',
50
 *                  ],
51
 *              ]
52
 *          ]
53
 *      ];
54
 *  }
55
 * ============================================================
56
 *
57
 * @property string $entityName
58
 * @property string $entityId
59
 * @property ActiveRecord $owner
60
 */
61
class ActiveLogBehavior extends Behavior
62
{
63
    /**
64
     * @event MessageEvent an event that is triggered before inserting a record.
65
     * You may add in to the [[MessageEvent::append]] your custom log message.
66
     * @since 1.5.3
67
     */
68
    public const EVENT_BEFORE_SAVE_MESSAGE = 'beforeSaveMessage';
69
    /**
70
     * @event Event an event that is triggered after inserting a record.
71
     * @since 1.5.3
72
     */
73
    public const EVENT_AFTER_SAVE_MESSAGE = 'afterSaveMessage';
74
75
    public bool $softDelete = false;
76
    /** @since 1.6.0 */
77
    public ?\Closure $beforeSaveMessage = null;
78
    /**
79
     * [
80
     *  // simple attribute
81
     *  'title',
82
     *
83
     *  // simple boolean attribute
84
     *  'is_publish',
85
     *
86
     *  // the value of the attribute is a item in the list
87
     *  // => $this->getStatusList()
88
     *  'status' => [
89
     *      'list' => 'statusList'
90
     *  ],
91
     *
92
     *  // the attribute value is the [id] of the relation model
93
     *  'owner_id' => [
94
     *      'relation' => 'user',
95
     *      'attribute' => 'username'
96
     *  ]
97
     * ]
98
     */
99
    public array $attributes = [];
100
101
    public bool $identicalAttributes = false;
102
    /**
103
     * A PHP callable that replaces the default implementation of [[isEmpty()]].
104
     * @since 1.5.2
105
     */
106
    public ?\Closure $isEmpty = null;
107
    /**
108
     * @var \Closure|array|string custom method to getEntityName
109
     * the callback function must return a string
110
     */
111
    public $getEntityName;
112
    /**
113
     * @var \Closure|array|string custom method to getEntityId
114
     * the callback function can return a string or array
115
     */
116
    public $getEntityId;
117
    /**
118
     * [
119
     *  'title' => [
120
     *      'new' => ['value' => 'New title'],
121
     *  ],
122
     *  'is_publish' => [
123
     *      'old' => ['value' => false],
124
     *      'new' => ['value' => true],
125
     *  ],
126
     *  'status' => [
127
     *      'old' => ['id' => 0, 'value' => 'Disabled'],
128
     *      'new' => ['id' => 1, 'value' => 'Active'],
129
     *  ],
130
     *  'owner_id' => [
131
     *      'old' => ['id' => 1, 'value' => 'admin'],
132
     *      'new' => ['id' => 2, 'value' => 'lucy'],
133
     *  ]
134
     * ]
135
     */
136
    private array $changedAttributes = [];
137
138
    private string $action;
139
140
    private ManagerInterface $logger;
141
142 28
    public function __construct(
143
        ManagerInterface $logger,
144
        array            $config = []
145
    )
146
    {
147 28
        $this->logger = $logger;
148 28
        parent::__construct($config);
149
    }
150
151 28
    public function init(): void
152
    {
153 28
        $this->initAttributes();
154
    }
155
156 28
    private function initAttributes(): void
157
    {
158 28
        foreach ($this->attributes as $key => $value) {
159 27
            if (is_int($key)) {
160 27
                unset($this->attributes[$key]);
161 27
                $this->attributes[$value] = [];
162
            }
163
        }
164
    }
165
166 28
    public function events(): array
167
    {
168 28
        return [
169 28
            ActiveRecord::EVENT_BEFORE_INSERT => [$this, 'beforeSave'],
170 28
            ActiveRecord::EVENT_BEFORE_UPDATE => [$this, 'beforeSave'],
171 28
            ActiveRecord::EVENT_BEFORE_DELETE => [$this, 'beforeDelete'],
172 28
            ActiveRecord::EVENT_AFTER_INSERT => [$this, 'afterSave'],
173 28
            ActiveRecord::EVENT_AFTER_UPDATE => [$this, 'afterSave'],
174 28
        ];
175
    }
176
177 27
    public function beforeSave(): void
178
    {
179 27
        $this->changedAttributes = $this->prepareChangedAttributes();
180 25
        $this->action = $this->owner->getIsNewRecord() ? 'created' : 'updated';
181
    }
182
183 25
    public function afterSave(): void
184
    {
185 25
        if (empty($this->changedAttributes)) {
186 4
            return;
187
        }
188 25
        $this->saveMessage($this->action, $this->changedAttributes);
189
    }
190
191 4
    public function beforeDelete(): void
192
    {
193 4
        if (false === $this->softDelete) {
194 3
            $this->deleteEntity();
195
        }
196 4
        $this->saveMessage('deleted', $this->prepareChangedAttributes(true));
197
    }
198
199 27
    private function prepareChangedAttributes(bool $unset = false): array
200
    {
201 27
        $result = [];
202 27
        foreach ($this->attributes as $attribute => $options) {
203 27
            $old = $this->owner->getOldAttribute($attribute);
204 27
            $new = false === $unset ? $this->owner->getAttribute($attribute) : null;
205
206 27
            if ($this->isEmpty($old) && $this->isEmpty($new)) {
207 27
                continue;
208
            }
209 27
            if (false === $unset && false === $this->owner->isAttributeChanged($attribute, $this->identicalAttributes)) {
210 13
                continue;
211
            }
212 27
            $result[$attribute] = $this->resolveStoreValues($old, $new, $options);
213
        }
214 25
        return $result;
215
    }
216
217
    /**
218
     * @param string|int|null $old_id
219
     * @param string|int|null $new_id
220
     */
221 27
    protected function resolveStoreValues($old_id, $new_id, array $options): array
222
    {
223 27
        if (isset($options['list'])) {
224 25
            $value = $this->resolveListValues($old_id, $new_id, $options['list']);
225 25
        } elseif (isset($options['relation'], $options['attribute'])) {
226 20
            $value = $this->resolveRelationValues($old_id, $new_id, $options['relation'], $options['attribute']);
227
        } else {
228 23
            $value = $this->resolveSimpleValues($old_id, $new_id);
229
        }
230 25
        return $value;
231
    }
232
233
    /**
234
     * @param string|int|null $old_id
235
     * @param string|int|null $new_id
236
     */
237 23
    private function resolveSimpleValues($old_id, $new_id): array
238
    {
239 23
        return [
240 23
            'old' => ['value' => $old_id],
241 23
            'new' => ['value' => $new_id],
242 23
        ];
243
    }
244
245
    /**
246
     * @param string|int|array|null $old_id
247
     * @param string|int|array|null $new_id
248
     */
249 25
    private function resolveListValues($old_id, $new_id, string $listName): array
250
    {
251 25
        $old = $new = [];
252 25
        $old['id'] = $old_id;
253 25
        $new['id'] = $new_id;
254 25
        $list = [];
255
256 25
        if (is_array($old_id) || is_array($new_id)) {
257 1
            $list = ArrayHelper::getValue($this->owner, $listName);
258
        }
259 25
        if (is_array($old_id)) {
260 1
            $old['value'] = array_intersect_key($list, array_flip($old_id));
261 25
        } elseif ($old_id) {
262 4
            $old['value'] = ArrayHelper::getValue($this->owner, [$listName, $old_id]);
263
        } else {
264 25
            $old['value'] = null;
265
        }
266 25
        if (is_array($new_id)) {
267 1
            $new['value'] = array_intersect_key($list, array_flip($new_id));
268 25
        } elseif ($new_id) {
269 24
            $new['value'] = ArrayHelper::getValue($this->owner, [$listName, $new_id]);
270
        } else {
271 4
            $new['value'] = null;
272
        }
273 25
        return [
274 25
            'old' => $old,
275 25
            'new' => $new
276 25
        ];
277
    }
278
279
    /**
280
     * @param string|int|null $old_id
281
     * @param string|int|null $new_id
282
     */
283 20
    private function resolveRelationValues($old_id, $new_id, string $relation, string $attribute): array
284
    {
285 20
        $old = $new = [];
286 20
        $old['id'] = $old_id;
287 20
        $new['id'] = $new_id;
288
289 20
        $relationQuery = clone $this->owner->getRelation($relation);
290 19
        if (count($relationQuery->link) > 1) {
291 1
            throw new InvalidConfigException('Relation model can only be linked through one primary key.');
292
        }
293 18
        $relationQuery->primaryModel = null;
294 18
        $idAttribute = array_keys($relationQuery->link)[0];
295 18
        $targetId = array_filter([$old_id, $new_id]);
296
297 18
        $relationModels = $relationQuery
298 18
            ->where([$idAttribute => $targetId])
299 18
            ->indexBy($idAttribute)
300 18
            ->limit(count($targetId))
301 18
            ->all();
302
303 18
        if ($old_id) {
304 5
            $old['value'] = ArrayHelper::getValue($relationModels, [$old_id, $attribute]);
305
        } else {
306 18
            $old['value'] = null;
307
        }
308 18
        if ($new_id) {
309 18
            $new['value'] = ArrayHelper::getValue($relationModels, [$new_id, $attribute]);
310
        } else {
311 2
            $new['value'] = null;
312
        }
313 18
        return [
314 18
            'old' => $old,
315 18
            'new' => $new
316 18
        ];
317
    }
318
319 3
    protected function deleteEntity(): void
320
    {
321 3
        $this->logger->delete(new DeleteCommand([
322 3
            'entityName' => $this->getEntityName(),
323 3
            'entityId' => $this->getEntityId(),
324 3
        ]));
325
    }
326
327 25
    protected function saveMessage(string $action, array $data): void
328
    {
329 25
        $data = $this->beforeSaveMessage($data);
330 25
        $this->addLog($data, $action);
331 25
        $this->afterSaveMessage();
332
    }
333
334
    /**
335
     * @param string|array $data
336
     * @since 1.7.0
337
     */
338 25
    public function addLog($data, string $action = null): bool
339
    {
340 25
        $message = $this->logger->createMessageBuilder($this->getEntityName())
341 25
            ->withEntityId($this->getEntityId())
342 25
            ->withAction($action)
0 ignored issues
show
Bug introduced by
It seems like $action can also be of type null; however, parameter $action of lav45\activityLogger\Mes...Interface::withAction() 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

342
            ->withAction(/** @scrutinizer ignore-type */ $action)
Loading history...
343 25
            ->withData($data)
344 25
            ->build(time());
345
346 25
        return $this->logger->log($message);
347
    }
348
349
    /**
350
     * @since 1.5.3
351
     */
352 25
    public function beforeSaveMessage(array $data): array
353
    {
354 25
        if (null !== $this->beforeSaveMessage) {
355 1
            return call_user_func($this->beforeSaveMessage, $data);
356
        }
357 24
        $name = self::EVENT_BEFORE_SAVE_MESSAGE;
358 24
        if (method_exists($this->owner, $name)) {
359 1
            return $this->owner->$name($data);
360
        }
361 23
        $event = new MessageEvent();
362 23
        $event->logData = $data;
363 23
        $this->owner->trigger($name, $event);
364 23
        return $event->logData;
365
    }
366
367
    /**
368
     * @since 1.5.3
369
     */
370 25
    public function afterSaveMessage(): void
371
    {
372 25
        $name = self::EVENT_AFTER_SAVE_MESSAGE;
373 25
        if (method_exists($this->owner, $name)) {
374 1
            $this->owner->$name();
375
        } else {
376 24
            $this->owner->trigger($name);
377
        }
378
    }
379
380 25
    public function getEntityName(): string
381
    {
382 25
        if (is_string($this->getEntityName)) {
383 25
            return $this->getEntityName;
384
        }
385 1
        if (is_callable($this->getEntityName)) {
386 1
            return call_user_func($this->getEntityName);
387
        }
388 1
        $class = get_class($this->owner);
389 1
        $class = StringHelper::basename($class);
390 1
        $this->getEntityName = Inflector::camel2id($class, '_');
391 1
        return $this->getEntityName;
392
    }
393
394 26
    public function getEntityId(): string
395
    {
396 26
        if (null === $this->getEntityId) {
397 26
            $result = $this->owner->getPrimaryKey();
398 4
        } elseif (is_callable($this->getEntityId)) {
399 3
            $result = call_user_func($this->getEntityId);
400
        } else {
401 1
            $result = $this->getEntityId;
402
        }
403 26
        if ($this->isEmpty($result)) {
404 1
            throw new InvalidValueException('the property "entityId" can not be empty');
405
        }
406 25
        if (is_array($result)) {
407 1
            ksort($result);
408 1
            $result = json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
409
        }
410 25
        return $result;
411
    }
412
413
    /**
414
     * Checks if the given value is empty.
415
     * A value is considered empty if it is null, an empty array, or an empty string.
416
     * Note that this method is different from PHP empty(). It will return false when the value is 0.
417
     * @param mixed $value the value to be checked
418
     * @return bool whether the value is empty
419
     * @since 1.5.2
420
     */
421 28
    public function isEmpty($value): bool
422
    {
423 28
        if (null !== $this->isEmpty) {
424 1
            return call_user_func($this->isEmpty, $value);
425
        }
426 27
        return null === $value || '' === $value || [] === $value;
427
    }
428
}