ActiveLogBehavior::resolveListValues()   B
last analyzed

Complexity

Conditions 7
Paths 18

Size

Total Lines 27
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 20
CRAP Score 7

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 21
dl 0
loc 27
rs 8.6506
c 2
b 0
f 0
ccs 20
cts 20
cp 1
cc 7
nc 18
nop 3
crap 7
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 !== null) {
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 !== null) {
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
        /** @var \yii\db\ActiveQueryInterface $relationQuery */
291 20
        $relationQuery = clone $this->owner->getRelation($relation);
292 19
        if (count($relationQuery->link) > 1) {
293 1
            throw new InvalidConfigException('Relation model can only be linked through one primary key.');
294
        }
295 18
        $relationQuery->primaryModel = null;
296 18
        $idAttribute = array_keys($relationQuery->link)[0];
297 18
        $targetId = array_filter([$old_id, $new_id]);
298
299 18
        $relationModels = $relationQuery
300 18
            ->where([$idAttribute => $targetId])
301 18
            ->indexBy($idAttribute)
302 18
            ->limit(count($targetId))
303 18
            ->all();
304
305 18
        if ($old_id !== null) {
306 5
            $old['value'] = ArrayHelper::getValue($relationModels, [$old_id, $attribute]);
307
        } else {
308 18
            $old['value'] = null;
309
        }
310 18
        if ($new_id !== null) {
311 18
            $new['value'] = ArrayHelper::getValue($relationModels, [$new_id, $attribute]);
312
        } else {
313 2
            $new['value'] = null;
314
        }
315 18
        return [
316 18
            'old' => $old,
317 18
            'new' => $new
318 18
        ];
319
    }
320
321 3
    protected function deleteEntity(): void
322
    {
323 3
        $this->logger->delete(new DeleteCommand([
324 3
            'entityName' => $this->getEntityName(),
325 3
            'entityId' => $this->getEntityId(),
326 3
        ]));
327
    }
328
329 26
    protected function saveMessage(string $action, array $data): void
330
    {
331 26
        $data = $this->beforeSaveMessage($data);
332 26
        $this->addLog($data, $action);
333 26
        $this->afterSaveMessage();
334
    }
335
336
    /**
337
     * @param string|array $data
338
     * @since 1.7.0
339
     */
340 26
    public function addLog($data, string $action = null): bool
341
    {
342 26
        $message = $this->logger->createMessageBuilder($this->getEntityName())
343 26
            ->withEntityId($this->getEntityId())
344 26
            ->withAction($action)
345 26
            ->withData($data)
346 26
            ->build(time());
347
348 26
        return $this->logger->log($message);
349
    }
350
351
    /**
352
     * @since 1.5.3
353
     */
354 26
    public function beforeSaveMessage(array $data): array
355
    {
356 26
        if (null !== $this->beforeSaveMessage) {
357 1
            return call_user_func($this->beforeSaveMessage, $data);
358
        }
359 25
        $name = self::EVENT_BEFORE_SAVE_MESSAGE;
360 25
        if (method_exists($this->owner, $name)) {
361 1
            return $this->owner->$name($data);
362
        }
363 24
        $event = new MessageEvent();
364 24
        $event->logData = $data;
365 24
        $this->owner->trigger($name, $event);
366 24
        return $event->logData;
367
    }
368
369
    /**
370
     * @since 1.5.3
371
     */
372 26
    public function afterSaveMessage(): void
373
    {
374 26
        $name = self::EVENT_AFTER_SAVE_MESSAGE;
375 26
        if (method_exists($this->owner, $name)) {
376 1
            $this->owner->$name();
377
        } else {
378 25
            $this->owner->trigger($name);
379
        }
380
    }
381
382 26
    public function getEntityName(): string
383
    {
384 26
        if (is_string($this->getEntityName)) {
385 26
            return $this->getEntityName;
386
        }
387 1
        if (is_callable($this->getEntityName)) {
388 1
            return call_user_func($this->getEntityName);
389
        }
390 1
        $class = get_class($this->owner);
391 1
        $class = StringHelper::basename($class);
392 1
        $this->getEntityName = Inflector::camel2id($class, '_');
393 1
        return $this->getEntityName;
394
    }
395
396 27
    public function getEntityId(): string
397
    {
398 27
        if (null === $this->getEntityId) {
399 27
            $result = $this->owner->getPrimaryKey();
400 4
        } elseif (is_callable($this->getEntityId)) {
401 3
            $result = call_user_func($this->getEntityId);
402
        } else {
403 1
            $result = $this->getEntityId;
404
        }
405 27
        if ($this->isEmpty($result)) {
406 1
            throw new InvalidValueException('the property "entityId" can not be empty');
407
        }
408 26
        if (is_array($result)) {
409 1
            ksort($result);
410 1
            $result = json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
411
        }
412 26
        return $result;
413
    }
414
415
    /**
416
     * Checks if the given value is empty.
417
     * A value is considered empty if it is null, an empty array, or an empty string.
418
     * Note that this method is different from PHP empty(). It will return false when the value is 0.
419
     * @param mixed $value the value to be checked
420
     * @return bool whether the value is empty
421
     * @since 1.5.2
422
     */
423 29
    public function isEmpty($value): bool
424
    {
425 29
        if (null !== $this->isEmpty) {
426 1
            return call_user_func($this->isEmpty, $value);
427
        }
428 28
        return null === $value || '' === $value || [] === $value;
429
    }
430
}