Passed
Push — master ( 9cc54a...5a5020 )
by Loban
02:57
created

ActiveLogBehavior::resolveListValues()   B

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