ActiveLogBehavior::addLog()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 9
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 6
c 1
b 0
f 0
dl 0
loc 9
ccs 7
cts 7
cp 1
rs 10
cc 1
nc 1
nop 2
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 29
    public function __construct(
143
        ManagerInterface $logger,
144
        array            $config = []
145
    )
146
    {
147 29
        $this->logger = $logger;
148 29
        parent::__construct($config);
149
    }
150
151 29
    public function init(): void
152
    {
153 29
        $this->initAttributes();
154
    }
155
156 29
    private function initAttributes(): void
157
    {
158 29
        foreach ($this->attributes as $key => $value) {
159 28
            if (is_int($key)) {
160 28
                unset($this->attributes[$key]);
161 28
                $this->attributes[$value] = [];
162
            }
163
        }
164
    }
165
166 29
    public function events(): array
167
    {
168 29
        return [
169 29
            ActiveRecord::EVENT_BEFORE_INSERT => [$this, 'beforeSave'],
170 29
            ActiveRecord::EVENT_BEFORE_UPDATE => [$this, 'beforeSave'],
171 29
            ActiveRecord::EVENT_BEFORE_DELETE => [$this, 'beforeDelete'],
172 29
            ActiveRecord::EVENT_AFTER_INSERT => [$this, 'afterSave'],
173 29
            ActiveRecord::EVENT_AFTER_UPDATE => [$this, 'afterSave'],
174 29
        ];
175
    }
176
177 28
    public function beforeSave(): void
178
    {
179 28
        $this->changedAttributes = $this->prepareChangedAttributes();
180 26
        $this->action = $this->owner->getIsNewRecord() ? 'created' : 'updated';
181
    }
182
183 26
    public function afterSave(): void
184
    {
185 26
        if (empty($this->changedAttributes)) {
186 4
            return;
187
        }
188 26
        $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 28
    private function prepareChangedAttributes(bool $unset = false): array
200
    {
201 28
        $result = [];
202 28
        foreach ($this->attributes as $attribute => $options) {
203 28
            $old = $this->owner->getOldAttribute($attribute);
204 28
            $new = false === $unset ? $this->owner->getAttribute($attribute) : null;
205
206 28
            if ($this->isEmpty($old) && $this->isEmpty($new)) {
207 28
                continue;
208
            }
209 28
            if (false === $unset && false === $this->owner->isAttributeChanged($attribute, $this->identicalAttributes)) {
210 13
                continue;
211
            }
212 28
            $result[$attribute] = $this->resolveStoreValues($old, $new, $options);
213
        }
214 26
        return $result;
215
    }
216
217
    /**
218
     * @param string|int|null $old_id
219
     * @param string|int|null $new_id
220
     */
221 28
    protected function resolveStoreValues($old_id, $new_id, array $options): array
222
    {
223 28
        if (isset($options['list'])) {
224 26
            $value = $this->resolveListValues($old_id, $new_id, $options['list']);
225 26
        } elseif (isset($options['relation'], $options['attribute'])) {
226 20
            $value = $this->resolveRelationValues($old_id, $new_id, $options['relation'], $options['attribute']);
227
        } else {
228 24
            $value = $this->resolveSimpleValues($old_id, $new_id);
229
        }
230 26
        return $value;
231
    }
232
233
    /**
234
     * @param string|int|null $old_id
235
     * @param string|int|null $new_id
236
     */
237 24
    private function resolveSimpleValues($old_id, $new_id): array
238
    {
239 24
        return [
240 24
            'old' => ['value' => $old_id],
241 24
            'new' => ['value' => $new_id],
242 24
        ];
243
    }
244
245
    /**
246
     * @param string|int|array|null $old_id
247
     * @param string|int|array|null $new_id
248
     * @param string|\Closure $listName
249
     */
250 26
    private function resolveListValues($old_id, $new_id, $listName): array
251
    {
252 26
        $old = $new = [];
253 26
        $old['id'] = $old_id;
254 26
        $new['id'] = $new_id;
255 26
        $list = [];
256
257 26
        if (is_array($old_id) || is_array($new_id)) {
258 1
            $list = ArrayHelper::getValue($this->owner, $listName);
259
        }
260 26
        if (is_array($old_id)) {
261 1
            $old['value'] = array_intersect_key($list, array_flip($old_id));
262 26
        } elseif ($old_id) {
263 4
            $old['value'] = ArrayHelper::getValue($this->owner, [$listName, $old_id]);
264
        } else {
265 26
            $old['value'] = null;
266
        }
267 26
        if (is_array($new_id)) {
268 1
            $new['value'] = array_intersect_key($list, array_flip($new_id));
269 26
        } elseif ($new_id) {
270 25
            $new['value'] = ArrayHelper::getValue($this->owner, [$listName, $new_id]);
271
        } else {
272 4
            $new['value'] = null;
273
        }
274 26
        return [
275 26
            'old' => $old,
276 26
            'new' => $new
277 26
        ];
278
    }
279
280
    /**
281
     * @param string|int|null $old_id
282
     * @param string|int|null $new_id
283
     */
284 20
    private function resolveRelationValues($old_id, $new_id, string $relation, string $attribute): array
285
    {
286 20
        $old = $new = [];
287 20
        $old['id'] = $old_id;
288 20
        $new['id'] = $new_id;
289
290 20
        $relationQuery = clone $this->owner->getRelation($relation);
291 19
        if (count($relationQuery->link) > 1) {
292 1
            throw new InvalidConfigException('Relation model can only be linked through one primary key.');
293
        }
294 18
        $relationQuery->primaryModel = null;
295 18
        $idAttribute = array_keys($relationQuery->link)[0];
296 18
        $targetId = array_filter([$old_id, $new_id]);
297
298 18
        $relationModels = $relationQuery
299 18
            ->where([$idAttribute => $targetId])
300 18
            ->indexBy($idAttribute)
301 18
            ->limit(count($targetId))
302 18
            ->all();
303
304 18
        if ($old_id) {
305 5
            $old['value'] = ArrayHelper::getValue($relationModels, [$old_id, $attribute]);
306
        } else {
307 18
            $old['value'] = null;
308
        }
309 18
        if ($new_id) {
310 18
            $new['value'] = ArrayHelper::getValue($relationModels, [$new_id, $attribute]);
311
        } else {
312 2
            $new['value'] = null;
313
        }
314 18
        return [
315 18
            'old' => $old,
316 18
            'new' => $new
317 18
        ];
318
    }
319
320 3
    protected function deleteEntity(): void
321
    {
322 3
        $this->logger->delete(new DeleteCommand([
323 3
            'entityName' => $this->getEntityName(),
324 3
            'entityId' => $this->getEntityId(),
325 3
        ]));
326
    }
327
328 26
    protected function saveMessage(string $action, array $data): void
329
    {
330 26
        $data = $this->beforeSaveMessage($data);
331 26
        $this->addLog($data, $action);
332 26
        $this->afterSaveMessage();
333
    }
334
335
    /**
336
     * @param string|array $data
337
     * @since 1.7.0
338
     */
339 26
    public function addLog($data, string $action = null): bool
340
    {
341 26
        $message = $this->logger->createMessageBuilder($this->getEntityName())
342 26
            ->withEntityId($this->getEntityId())
343 26
            ->withAction($action)
344 26
            ->withData($data)
345 26
            ->build(time());
346
347 26
        return $this->logger->log($message);
348
    }
349
350
    /**
351
     * @since 1.5.3
352
     */
353 26
    public function beforeSaveMessage(array $data): array
354
    {
355 26
        if (null !== $this->beforeSaveMessage) {
356 1
            return call_user_func($this->beforeSaveMessage, $data);
357
        }
358 25
        $name = self::EVENT_BEFORE_SAVE_MESSAGE;
359 25
        if (method_exists($this->owner, $name)) {
360 1
            return $this->owner->$name($data);
361
        }
362 24
        $event = new MessageEvent();
363 24
        $event->logData = $data;
364 24
        $this->owner->trigger($name, $event);
365 24
        return $event->logData;
366
    }
367
368
    /**
369
     * @since 1.5.3
370
     */
371 26
    public function afterSaveMessage(): void
372
    {
373 26
        $name = self::EVENT_AFTER_SAVE_MESSAGE;
374 26
        if (method_exists($this->owner, $name)) {
375 1
            $this->owner->$name();
376
        } else {
377 25
            $this->owner->trigger($name);
378
        }
379
    }
380
381 26
    public function getEntityName(): string
382
    {
383 26
        if (is_string($this->getEntityName)) {
384 26
            return $this->getEntityName;
385
        }
386 1
        if (is_callable($this->getEntityName)) {
387 1
            return call_user_func($this->getEntityName);
388
        }
389 1
        $class = get_class($this->owner);
390 1
        $class = StringHelper::basename($class);
391 1
        $this->getEntityName = Inflector::camel2id($class, '_');
392 1
        return $this->getEntityName;
393
    }
394
395 27
    public function getEntityId(): string
396
    {
397 27
        if (null === $this->getEntityId) {
398 27
            $result = $this->owner->getPrimaryKey();
399 4
        } elseif (is_callable($this->getEntityId)) {
400 3
            $result = call_user_func($this->getEntityId);
401
        } else {
402 1
            $result = $this->getEntityId;
403
        }
404 27
        if ($this->isEmpty($result)) {
405 1
            throw new InvalidValueException('the property "entityId" can not be empty');
406
        }
407 26
        if (is_array($result)) {
408 1
            ksort($result);
409 1
            $result = json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
410
        }
411 26
        return $result;
412
    }
413
414
    /**
415
     * Checks if the given value is empty.
416
     * A value is considered empty if it is null, an empty array, or an empty string.
417
     * Note that this method is different from PHP empty(). It will return false when the value is 0.
418
     * @param mixed $value the value to be checked
419
     * @return bool whether the value is empty
420
     * @since 1.5.2
421
     */
422 29
    public function isEmpty($value): bool
423
    {
424 29
        if (null !== $this->isEmpty) {
425 1
            return call_user_func($this->isEmpty, $value);
426
        }
427 28
        return null === $value || '' === $value || [] === $value;
428
    }
429
}