Completed
Push — master ( 66a821...e53f10 )
by Damien
05:39
created

AuditSubscriber::associateOrDissociate()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 20
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 13
nc 2
nop 5
dl 0
loc 20
rs 9.8333
c 0
b 0
f 0
1
<?php
2
3
namespace DH\DoctrineAuditBundle\EventSubscriber;
4
5
use DH\DoctrineAuditBundle\AuditConfiguration;
6
use DH\DoctrineAuditBundle\DBAL\AuditLogger;
7
use DH\DoctrineAuditBundle\User\UserInterface;
8
use Doctrine\Common\EventSubscriber;
9
use Doctrine\DBAL\Logging\LoggerChain;
10
use Doctrine\DBAL\Logging\SQLLogger;
11
use Doctrine\DBAL\Types\Type;
12
use Doctrine\ORM\EntityManager;
13
use Doctrine\ORM\Event\LifecycleEventArgs;
14
use Doctrine\ORM\Event\OnFlushEventArgs;
15
use Doctrine\ORM\Events;
16
17
class AuditSubscriber implements EventSubscriber
18
{
19
    private $configuration;
20
21
    /**
22
     * @var ?SQLLogger
23
     */
24
    private $loggerBackup;
25
26
    private $inserted = [];     // [$source, $changeset]
27
    private $updated = [];      // [$source, $changeset]
28
    private $removed = [];      // [$source, $id]
29
    private $associated = [];   // [$source, $target, $mapping]
30
    private $dissociated = [];  // [$source, $target, $id, $mapping]
31
32
    public function __construct(AuditConfiguration $configuration)
33
    {
34
        $this->configuration = $configuration;
35
    }
36
37
    /**
38
     * Handles soft-delete events from Gedmo\SoftDeleteable filter.
39
     *
40
     * @see https://symfony.com/doc/current/bundles/StofDoctrineExtensionsBundle/index.html
41
     *
42
     * @param LifecycleEventArgs $args
43
     *
44
     * @throws \Doctrine\DBAL\DBALException
45
     * @throws \Doctrine\ORM\Mapping\MappingException
46
     */
47
    public function preSoftDelete(LifecycleEventArgs $args): void
48
    {
49
        $entity = $args->getEntity();
50
        $em = $args->getEntityManager();
51
52
        if ($this->configuration->isAudited($entity)) {
53
            $this->removed[] = [
54
                $entity,
55
                $this->id($em, $entity),
56
            ];
57
        }
58
    }
59
60
    /**
61
     * It is called inside EntityManager#flush() after the changes to all the managed entities
62
     * and their associations have been computed.
63
     *
64
     * @see https://www.doctrine-project.org/projects/doctrine-orm/en/latest/reference/events.html#onflush
65
     *
66
     * @param OnFlushEventArgs $args
67
     *
68
     * @throws \Doctrine\DBAL\DBALException
69
     * @throws \Doctrine\ORM\Mapping\MappingException
70
     */
71
    public function onFlush(OnFlushEventArgs $args): void
72
    {
73
        $em = $args->getEntityManager();
74
        $uow = $em->getUnitOfWork();
75
76
        // extend the SQL logger
77
        $this->loggerBackup = $em->getConnection()->getConfiguration()->getSQLLogger();
78
        $loggerChain = new LoggerChain();
79
        $loggerChain->addLogger(new AuditLogger(function () use ($em) {
80
            $this->flush($em);
81
        }));
82
        if ($this->loggerBackup instanceof SQLLogger) {
83
            $loggerChain->addLogger($this->loggerBackup);
84
        }
85
        $em->getConnection()->getConfiguration()->setSQLLogger($loggerChain);
86
87
        foreach ($uow->getScheduledEntityInsertions() as $entity) {
88
            if ($this->configuration->isAudited($entity)) {
89
                $this->inserted[] = [
90
                    $entity,
91
                    $uow->getEntityChangeSet($entity),
92
                ];
93
            }
94
        }
95
        foreach ($uow->getScheduledEntityUpdates() as $entity) {
96
            if ($this->configuration->isAudited($entity)) {
97
                $this->updated[] = [
98
                    $entity,
99
                    $uow->getEntityChangeSet($entity),
100
                ];
101
            }
102
        }
103
        foreach ($uow->getScheduledEntityDeletions() as $entity) {
104
            if ($this->configuration->isAudited($entity)) {
105
                $uow->initializeObject($entity);
106
                $this->removed[] = [
107
                    $entity,
108
                    $this->id($em, $entity),
109
                ];
110
            }
111
        }
112
        foreach ($uow->getScheduledCollectionUpdates() as $collection) {
113
            if ($this->configuration->isAudited($collection->getOwner())) {
114
                $mapping = $collection->getMapping();
115
                foreach ($collection->getInsertDiff() as $entity) {
116
                    if ($this->configuration->isAudited($entity)) {
117
                        $this->associated[] = [
118
                            $collection->getOwner(),
119
                            $entity,
120
                            $mapping,
121
                        ];
122
                    }
123
                }
124
                foreach ($collection->getDeleteDiff() as $entity) {
125
                    if ($this->configuration->isAudited($entity)) {
126
                        $this->dissociated[] = [
127
                            $collection->getOwner(),
128
                            $entity,
129
                            $this->id($em, $entity),
130
                            $mapping,
131
                        ];
132
                    }
133
                }
134
            }
135
        }
136
        foreach ($uow->getScheduledCollectionDeletions() as $collection) {
137
            if ($this->configuration->isAudited($collection->getOwner())) {
138
                $mapping = $collection->getMapping();
139
                foreach ($collection->toArray() as $entity) {
140
                    if (!$this->configuration->isAudited($entity)) {
141
                        continue;
142
                    }
143
                    $this->dissociated[] = [
144
                        $collection->getOwner(),
145
                        $entity,
146
                        $this->id($em, $entity),
147
                        $mapping,
148
                    ];
149
                }
150
            }
151
        }
152
    }
153
154
    /**
155
     * Flushes pending data.
156
     *
157
     * @param EntityManager $em
158
     *
159
     * @throws \Doctrine\DBAL\DBALException
160
     * @throws \Doctrine\ORM\Mapping\MappingException
161
     */
162
    private function flush(EntityManager $em): void
163
    {
164
        $em->getConnection()->getConfiguration()->setSQLLogger($this->loggerBackup);
165
        $uow = $em->getUnitOfWork();
166
167
        foreach ($this->inserted as list($entity, $ch)) {
168
            // the changeset might be updated from UOW extra updates
169
            $ch = array_merge($ch, $uow->getEntityChangeSet($entity));
170
            $this->insert($em, $entity, $ch);
171
        }
172
173
        foreach ($this->updated as list($entity, $ch)) {
174
            // the changeset might be updated from UOW extra updates
175
            $ch = array_merge($ch, $uow->getEntityChangeSet($entity));
176
            $this->update($em, $entity, $ch);
177
        }
178
179
        foreach ($this->associated as list($source, $target, $mapping)) {
180
            $this->associate($em, $source, $target, $mapping);
181
        }
182
183
        foreach ($this->dissociated as list($source, $target, $id, $mapping)) {
184
            $this->dissociate($em, $source, $target, $mapping);
185
        }
186
187
        foreach ($this->removed as list($entity, $id)) {
188
            $this->remove($em, $entity, $id);
189
        }
190
191
        $this->inserted = [];
192
        $this->updated = [];
193
        $this->removed = [];
194
        $this->associated = [];
195
        $this->dissociated = [];
196
    }
197
198
    /**
199
     * Adds an insert entry to the audit table.
200
     *
201
     * @param EntityManager $em
202
     * @param object $entity
203
     * @param array $ch
204
     *
205
     * @throws \Doctrine\DBAL\DBALException
206
     * @throws \Doctrine\ORM\Mapping\MappingException
207
     */
208
    private function insert(EntityManager $em, $entity, array $ch): void
209
    {
210
        $meta = $em->getClassMetadata(\get_class($entity));
211
        $this->audit($em, [
212
            'action' => 'insert',
213
            'blame' => $this->blame(),
214
            'diff' => $this->diff($em, $entity, $ch),
215
            'table' => $meta->table['name'],
216
            'schema' => $meta->table['schema'] ?? null,
217
            'id' => $this->id($em, $entity),
218
        ]);
219
    }
220
221
    /**
222
     * Adds an update entry to the audit table.
223
     *
224
     * @param EntityManager $em
225
     * @param object $entity
226
     * @param array $ch
227
     *
228
     * @throws \Doctrine\DBAL\DBALException
229
     * @throws \Doctrine\ORM\Mapping\MappingException
230
     */
231
    private function update(EntityManager $em, $entity, array $ch): void
232
    {
233
        $diff = $this->diff($em, $entity, $ch);
234
        if (!$diff) {
235
            return; // if there is no entity diff, do not log it
236
        }
237
        $meta = $em->getClassMetadata(\get_class($entity));
238
        $this->audit($em, [
239
            'action' => 'update',
240
            'blame' => $this->blame(),
241
            'diff' => $diff,
242
            'table' => $meta->table['name'],
243
            'schema' => $meta->table['schema'] ?? null,
244
            'id' => $this->id($em, $entity),
245
        ]);
246
    }
247
248
    /**
249
     * Adds a remove entry to the audit table.
250
     *
251
     * @param EntityManager $em
252
     * @param object $entity
253
     * @param mixed $id
254
     *
255
     * @throws \Doctrine\DBAL\DBALException
256
     * @throws \Doctrine\ORM\Mapping\MappingException
257
     */
258
    private function remove(EntityManager $em, $entity, $id): void
259
    {
260
        $meta = $em->getClassMetadata(\get_class($entity));
261
        $this->audit($em, [
262
            'action' => 'remove',
263
            'blame' => $this->blame(),
264
            'diff' => $this->assoc($em, $entity, $id),
265
            'table' => $meta->table['name'],
266
            'schema' => $meta->table['schema'] ?? null,
267
            'id' => $id,
268
        ]);
269
    }
270
271
    /**
272
     * Adds an association entry to the audit table.
273
     *
274
     * @param EntityManager $em
275
     * @param object $source
276
     * @param object $target
277
     * @param array $mapping
278
     *
279
     * @throws \Doctrine\DBAL\DBALException
280
     * @throws \Doctrine\ORM\Mapping\MappingException
281
     */
282
    private function associate(EntityManager $em, $source, $target, array $mapping): void
283
    {
284
        $this->associateOrDissociate('associate', $em, $source, $target, $mapping);
285
    }
286
287
    /**
288
     * Adds a dissociation entry to the audit table.
289
     *
290
     * @param EntityManager $em
291
     * @param object $source
292
     * @param object $target
293
     * @param array $mapping
294
     *
295
     * @throws \Doctrine\DBAL\DBALException
296
     * @throws \Doctrine\ORM\Mapping\MappingException
297
     */
298
    private function dissociate(EntityManager $em, $source, $target, array $mapping): void
299
    {
300
        $this->associateOrDissociate('dissociate', $em, $source, $target, $mapping);
301
    }
302
303
    /**
304
     * Adds an association entry to the audit table.
305
     *
306
     * @param string $type
307
     * @param EntityManager $em
308
     * @param object $source
309
     * @param object $target
310
     * @param array $mapping
311
     *
312
     * @throws \Doctrine\DBAL\DBALException
313
     * @throws \Doctrine\ORM\Mapping\MappingException
314
     */
315
    private function associateOrDissociate(string $type, EntityManager $em, $source, $target, array $mapping): void
316
    {
317
        $meta = $em->getClassMetadata(\get_class($source));
318
        $data = [
319
            'action' => $type,
320
            'blame' => $this->blame(),
321
            'diff' => [
322
                'source' => $this->assoc($em, $source),
323
                'target' => $this->assoc($em, $target),
324
            ],
325
            'table' => $meta->table['name'],
326
            'schema' => $meta->table['schema'] ?? null,
327
            'id' => $this->id($em, $source),
328
        ];
329
330
        if (isset($mapping['joinTable']['name'])) {
331
            $data['diff']['table'] = $mapping['joinTable']['name'];
332
        }
333
334
        $this->audit($em, $data);
335
    }
336
337
    /**
338
     * Adds an entry to the audit table.
339
     *
340
     * @param EntityManager $em
341
     * @param array         $data
342
     *
343
     * @throws \Doctrine\DBAL\DBALException
344
     */
345
    private function audit(EntityManager $em, array $data): void
346
    {
347
        $schema = $data['schema'] ? $data['schema'].'.' : '';
348
        $auditTable = $schema.$this->configuration->getTablePrefix().$data['table'].$this->configuration->getTableSuffix();
349
        $fields = [
350
            'type' => ':type',
351
            'object_id' => ':object_id',
352
            'diffs' => ':diffs',
353
            'blame_id' => ':blame_id',
354
            'blame_user' => ':blame_user',
355
            'ip' => ':ip',
356
            'created_at' => ':created_at',
357
        ];
358
359
        $query = sprintf(
360
            'INSERT INTO %s (%s) VALUES (%s)',
361
            $auditTable,
362
            implode(', ', array_keys($fields)),
363
            implode(', ', array_values($fields))
364
        );
365
366
        $statement = $em->getConnection()->prepare($query);
367
368
        $dt = new \DateTime('now', new \DateTimeZone('UTC'));
369
        $statement->bindValue('type', $data['action']);
370
        $statement->bindValue('object_id', $data['id']);
371
        $statement->bindValue('diffs', json_encode($data['diff']));
372
        $statement->bindValue('blame_id', $data['blame']['user_id']);
373
        $statement->bindValue('blame_user', $data['blame']['username']);
374
        $statement->bindValue('ip', $data['blame']['client_ip']);
375
        $statement->bindValue('created_at', $dt->format('Y-m-d H:i:s'));
376
        $statement->execute();
377
    }
378
379
    /**
380
     * Returns the primary key value of an entity.
381
     *
382
     * @param EntityManager $em
383
     * @param object $entity
384
     *
385
     * @throws \Doctrine\DBAL\DBALException
386
     * @throws \Doctrine\ORM\Mapping\MappingException
387
     *
388
     * @return mixed
389
     */
390
    private function id(EntityManager $em, $entity)
391
    {
392
        $meta = $em->getClassMetadata(\get_class($entity));
393
        $pk = $meta->getSingleIdentifierFieldName();
394
395
        if (!isset($meta->fieldMappings[$pk])) {
396
            // Primary key is not part of fieldMapping
397
            // @see https://github.com/DamienHarper/DoctrineAuditBundle/issues/40
398
            // @see https://www.doctrine-project.org/projects/doctrine-orm/en/latest/tutorials/composite-primary-keys.html#identity-through-foreign-entities
399
            // We try to get it from associationMapping (will throw a MappingException if not available)
400
            $targetEntity = $meta->getReflectionProperty($pk)->getValue($entity);
401
402
            $mapping = $meta->getAssociationMapping($pk);
403
            $meta = $em->getClassMetadata($mapping['targetEntity']);
404
            $pk = $meta->getSingleIdentifierFieldName();
405
            $type = Type::getType($meta->fieldMappings[$pk]['type']);
406
407
            return $this->value($em, $type, $meta->getReflectionProperty($pk)->getValue($targetEntity));
408
        }
409
410
        $type = Type::getType($meta->fieldMappings[$pk]['type']);
411
412
        return $this->value($em, $type, $meta->getReflectionProperty($pk)->getValue($entity));
413
    }
414
415
    /**
416
     * Computes a usable diff.
417
     *
418
     * @param EntityManager $em
419
     * @param object $entity
420
     * @param array $ch
421
     *
422
     * @throws \Doctrine\DBAL\DBALException
423
     * @throws \Doctrine\ORM\Mapping\MappingException
424
     *
425
     * @return array
426
     */
427
    private function diff(EntityManager $em, $entity, array $ch): array
428
    {
429
        $meta = $em->getClassMetadata(\get_class($entity));
430
        $diff = [];
431
        foreach ($ch as $fieldName => list($old, $new)) {
432
            if ($meta->hasField($fieldName) && !isset($meta->embeddedClasses[$fieldName]) &&
433
                $this->configuration->isAuditedField($entity, $fieldName)
434
            ) {
435
                $mapping = $meta->fieldMappings[$fieldName];
436
                $type = Type::getType($mapping['type']);
437
                $o = $this->value($em, $type, $old);
438
                $n = $this->value($em, $type, $new);
439
                if ($o !== $n) {
440
                    $diff[$fieldName] = [
441
                        'old' => $o,
442
                        'new' => $n,
443
                    ];
444
                }
445
            } elseif ($meta->hasAssociation($fieldName) &&
446
                $meta->isSingleValuedAssociation($fieldName) &&
447
                $this->configuration->isAuditedField($entity, $fieldName)
448
            ) {
449
                $o = $this->assoc($em, $old);
450
                $n = $this->assoc($em, $new);
451
                if ($o !== $n) {
452
                    $diff[$fieldName] = [
453
                        'old' => $o,
454
                        'new' => $n,
455
                    ];
456
                }
457
            }
458
        }
459
460
        return $diff;
461
    }
462
463
    /**
464
     * Returns an array describing an association.
465
     *
466
     * @param EntityManager $em
467
     * @param object        $association
468
     * @param mixed         $id
469
     *
470
     * @throws \Doctrine\DBAL\DBALException
471
     * @throws \Doctrine\ORM\Mapping\MappingException
472
     *
473
     * @return array
474
     */
475
    private function assoc(EntityManager $em, $association = null, $id = null): ?array
476
    {
477
        if (null === $association) {
478
            return null;
479
        }
480
481
        $em->getUnitOfWork()->initializeObject($association); // ensure that proxies are initialized
482
        $meta = $em->getClassMetadata(\get_class($association));
483
        $pkName = $meta->getSingleIdentifierFieldName();
484
        $pkValue = $id ?? $this->id($em, $association);
485
        if (method_exists($association, '__toString')) {
486
            $label = (string) $association;
487
        } else {
488
            $label = \get_class($association).'#'.$pkValue;
489
        }
490
491
        return [
492
            'label' => $label,
493
            'class' => $meta->name,
494
            'table' => $meta->table['name'],
495
            $pkName => $pkValue,
496
        ];
497
    }
498
499
    /**
500
     * Type converts the input value and returns it.
501
     *
502
     * @param EntityManager $em
503
     * @param Type          $type
504
     * @param mixed         $value
505
     *
506
     * @throws \Doctrine\DBAL\DBALException
507
     *
508
     * @return mixed
509
     */
510
    private function value(EntityManager $em, Type $type, $value)
511
    {
512
        if (null === $value) {
513
            return null;
514
        }
515
516
        $platform = $em->getConnection()->getDatabasePlatform();
517
518
        switch ($type->getName()) {
519
            case Type::DECIMAL:
520
            case Type::BIGINT:
521
                $convertedValue = (string) $value;
522
                break;
523
524
            case Type::INTEGER:
525
            case Type::SMALLINT:
526
                $convertedValue = (int) $value;
527
                break;
528
529
            case Type::FLOAT:
530
            case Type::BOOLEAN:
531
                $convertedValue = $type->convertToPHPValue($value, $platform);
532
                break;
533
534
            default:
535
                $convertedValue = $type->convertToDatabaseValue($value, $platform);
536
        }
537
538
        return $convertedValue;
539
    }
540
541
    /**
542
     * Blames an audit operation.
543
     *
544
     * @return array
545
     */
546
    private function blame(): array
547
    {
548
        $user_id = null;
549
        $username = null;
550
        $client_ip = null;
551
552
        $request = $this->configuration->getRequestStack()->getCurrentRequest();
553
        if (null !== $request) {
554
            $client_ip = $request->getClientIp();
555
        }
556
557
        $user = $this->configuration->getUserProvider()->getUser();
558
        if ($user instanceof UserInterface) {
559
            $user_id = $user->getId();
560
            $username = $user->getUsername();
561
        }
562
563
        return [
564
            'user_id' => $user_id,
565
            'username' => $username,
566
            'client_ip' => $client_ip,
567
        ];
568
    }
569
570
    /**
571
     * {@inheritdoc}
572
     */
573
    public function getSubscribedEvents(): array
574
    {
575
        return [Events::onFlush, 'preSoftDelete'];
576
    }
577
}
578