Passed
Pull Request — master (#48)
by Damien
02:45
created

AuditSubscriber::collectScheduledDeletions()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 8
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 6
nc 3
nop 2
dl 0
loc 8
rs 10
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
        $this->collectScheduledInsertions($uow);
88
        $this->collectScheduledUpdates($uow);
89
        $this->collectScheduledDeletions($uow, $em);
90
        $this->collectScheduledCollectionUpdates($uow, $em);
91
        $this->collectScheduledCollectionDeletions($uow, $em);
92
    }
93
94
    /**
95
     * Flushes pending data.
96
     *
97
     * @param EntityManager $em
98
     *
99
     * @throws \Doctrine\DBAL\DBALException
100
     * @throws \Doctrine\ORM\Mapping\MappingException
101
     */
102
    private function flush(EntityManager $em): void
103
    {
104
        $em->getConnection()->getConfiguration()->setSQLLogger($this->loggerBackup);
105
        $uow = $em->getUnitOfWork();
106
107
        $this->processInsertions($em, $uow);
108
        $this->processUpdates($em, $uow);
109
        $this->processAssociations($em);
110
        $this->processDissociations($em);
111
        $this->processDeletions($em);
112
113
        $this->inserted = [];
114
        $this->updated = [];
115
        $this->removed = [];
116
        $this->associated = [];
117
        $this->dissociated = [];
118
    }
119
120
    /**
121
     * Adds an insert entry to the audit table.
122
     *
123
     * @param EntityManager $em
124
     * @param object        $entity
125
     * @param array         $ch
126
     *
127
     * @throws \Doctrine\DBAL\DBALException
128
     * @throws \Doctrine\ORM\Mapping\MappingException
129
     */
130
    private function insert(EntityManager $em, $entity, array $ch): void
131
    {
132
        $meta = $em->getClassMetadata(\get_class($entity));
133
        $this->audit($em, [
134
            'action' => 'insert',
135
            'blame' => $this->blame(),
136
            'diff' => $this->diff($em, $entity, $ch),
137
            'table' => $meta->table['name'],
138
            'schema' => $meta->table['schema'] ?? null,
139
            'id' => $this->id($em, $entity),
140
        ]);
141
    }
142
143
    /**
144
     * Adds an update entry to the audit table.
145
     *
146
     * @param EntityManager $em
147
     * @param object        $entity
148
     * @param array         $ch
149
     *
150
     * @throws \Doctrine\DBAL\DBALException
151
     * @throws \Doctrine\ORM\Mapping\MappingException
152
     */
153
    private function update(EntityManager $em, $entity, array $ch): void
154
    {
155
        $diff = $this->diff($em, $entity, $ch);
156
        if (!$diff) {
157
            return; // if there is no entity diff, do not log it
158
        }
159
        $meta = $em->getClassMetadata(\get_class($entity));
160
        $this->audit($em, [
161
            'action' => 'update',
162
            'blame' => $this->blame(),
163
            'diff' => $diff,
164
            'table' => $meta->table['name'],
165
            'schema' => $meta->table['schema'] ?? null,
166
            'id' => $this->id($em, $entity),
167
        ]);
168
    }
169
170
    /**
171
     * Adds a remove entry to the audit table.
172
     *
173
     * @param EntityManager $em
174
     * @param object        $entity
175
     * @param mixed         $id
176
     *
177
     * @throws \Doctrine\DBAL\DBALException
178
     * @throws \Doctrine\ORM\Mapping\MappingException
179
     */
180
    private function remove(EntityManager $em, $entity, $id): void
181
    {
182
        $meta = $em->getClassMetadata(\get_class($entity));
183
        $this->audit($em, [
184
            'action' => 'remove',
185
            'blame' => $this->blame(),
186
            'diff' => $this->assoc($em, $entity, $id),
187
            'table' => $meta->table['name'],
188
            'schema' => $meta->table['schema'] ?? null,
189
            'id' => $id,
190
        ]);
191
    }
192
193
    /**
194
     * Adds an association entry to the audit table.
195
     *
196
     * @param EntityManager $em
197
     * @param object        $source
198
     * @param object        $target
199
     * @param array         $mapping
200
     *
201
     * @throws \Doctrine\DBAL\DBALException
202
     * @throws \Doctrine\ORM\Mapping\MappingException
203
     */
204
    private function associate(EntityManager $em, $source, $target, array $mapping): void
205
    {
206
        $this->associateOrDissociate('associate', $em, $source, $target, $mapping);
207
    }
208
209
    /**
210
     * Adds a dissociation entry to the audit table.
211
     *
212
     * @param EntityManager $em
213
     * @param object        $source
214
     * @param object        $target
215
     * @param array         $mapping
216
     *
217
     * @throws \Doctrine\DBAL\DBALException
218
     * @throws \Doctrine\ORM\Mapping\MappingException
219
     */
220
    private function dissociate(EntityManager $em, $source, $target, array $mapping): void
221
    {
222
        $this->associateOrDissociate('dissociate', $em, $source, $target, $mapping);
223
    }
224
225
    /**
226
     * Adds an association entry to the audit table.
227
     *
228
     * @param string        $type
229
     * @param EntityManager $em
230
     * @param object        $source
231
     * @param object        $target
232
     * @param array         $mapping
233
     *
234
     * @throws \Doctrine\DBAL\DBALException
235
     * @throws \Doctrine\ORM\Mapping\MappingException
236
     */
237
    private function associateOrDissociate(string $type, EntityManager $em, $source, $target, array $mapping): void
238
    {
239
        $meta = $em->getClassMetadata(\get_class($source));
240
        $data = [
241
            'action' => $type,
242
            'blame' => $this->blame(),
243
            'diff' => [
244
                'source' => $this->assoc($em, $source),
245
                'target' => $this->assoc($em, $target),
246
            ],
247
            'table' => $meta->table['name'],
248
            'schema' => $meta->table['schema'] ?? null,
249
            'id' => $this->id($em, $source),
250
        ];
251
252
        if (isset($mapping['joinTable']['name'])) {
253
            $data['diff']['table'] = $mapping['joinTable']['name'];
254
        }
255
256
        $this->audit($em, $data);
257
    }
258
259
    /**
260
     * Adds an entry to the audit table.
261
     *
262
     * @param EntityManager $em
263
     * @param array         $data
264
     *
265
     * @throws \Doctrine\DBAL\DBALException
266
     */
267
    private function audit(EntityManager $em, array $data): void
268
    {
269
        $schema = $data['schema'] ? $data['schema'].'.' : '';
270
        $auditTable = $schema.$this->configuration->getTablePrefix().$data['table'].$this->configuration->getTableSuffix();
271
        $fields = [
272
            'type' => ':type',
273
            'object_id' => ':object_id',
274
            'diffs' => ':diffs',
275
            'blame_id' => ':blame_id',
276
            'blame_user' => ':blame_user',
277
            'ip' => ':ip',
278
            'created_at' => ':created_at',
279
        ];
280
281
        $query = sprintf(
282
            'INSERT INTO %s (%s) VALUES (%s)',
283
            $auditTable,
284
            implode(', ', array_keys($fields)),
285
            implode(', ', array_values($fields))
286
        );
287
288
        $statement = $em->getConnection()->prepare($query);
289
290
        $dt = new \DateTime('now', new \DateTimeZone('UTC'));
291
        $statement->bindValue('type', $data['action']);
292
        $statement->bindValue('object_id', $data['id']);
293
        $statement->bindValue('diffs', json_encode($data['diff']));
294
        $statement->bindValue('blame_id', $data['blame']['user_id']);
295
        $statement->bindValue('blame_user', $data['blame']['username']);
296
        $statement->bindValue('ip', $data['blame']['client_ip']);
297
        $statement->bindValue('created_at', $dt->format('Y-m-d H:i:s'));
298
        $statement->execute();
299
    }
300
301
    /**
302
     * Returns the primary key value of an entity.
303
     *
304
     * @param EntityManager $em
305
     * @param object        $entity
306
     *
307
     * @throws \Doctrine\DBAL\DBALException
308
     * @throws \Doctrine\ORM\Mapping\MappingException
309
     *
310
     * @return mixed
311
     */
312
    private function id(EntityManager $em, $entity)
313
    {
314
        $meta = $em->getClassMetadata(\get_class($entity));
315
        $pk = $meta->getSingleIdentifierFieldName();
316
317
        if (!isset($meta->fieldMappings[$pk])) {
318
            // Primary key is not part of fieldMapping
319
            // @see https://github.com/DamienHarper/DoctrineAuditBundle/issues/40
320
            // @see https://www.doctrine-project.org/projects/doctrine-orm/en/latest/tutorials/composite-primary-keys.html#identity-through-foreign-entities
321
            // We try to get it from associationMapping (will throw a MappingException if not available)
322
            $targetEntity = $meta->getReflectionProperty($pk)->getValue($entity);
323
324
            $mapping = $meta->getAssociationMapping($pk);
325
            $meta = $em->getClassMetadata($mapping['targetEntity']);
326
            $pk = $meta->getSingleIdentifierFieldName();
327
            $type = Type::getType($meta->fieldMappings[$pk]['type']);
328
329
            return $this->value($em, $type, $meta->getReflectionProperty($pk)->getValue($targetEntity));
330
        }
331
332
        $type = Type::getType($meta->fieldMappings[$pk]['type']);
333
334
        return $this->value($em, $type, $meta->getReflectionProperty($pk)->getValue($entity));
335
    }
336
337
    /**
338
     * Computes a usable diff.
339
     *
340
     * @param EntityManager $em
341
     * @param object        $entity
342
     * @param array         $ch
343
     *
344
     * @throws \Doctrine\DBAL\DBALException
345
     * @throws \Doctrine\ORM\Mapping\MappingException
346
     *
347
     * @return array
348
     */
349
    private function diff(EntityManager $em, $entity, array $ch): array
350
    {
351
        $meta = $em->getClassMetadata(\get_class($entity));
352
        $diff = [];
353
        foreach ($ch as $fieldName => list($old, $new)) {
354
            if ($meta->hasField($fieldName) && !isset($meta->embeddedClasses[$fieldName]) &&
355
                $this->configuration->isAuditedField($entity, $fieldName)
356
            ) {
357
                $mapping = $meta->fieldMappings[$fieldName];
358
                $type = Type::getType($mapping['type']);
359
                $o = $this->value($em, $type, $old);
360
                $n = $this->value($em, $type, $new);
361
                if ($o !== $n) {
362
                    $diff[$fieldName] = [
363
                        'old' => $o,
364
                        'new' => $n,
365
                    ];
366
                }
367
            } elseif ($meta->hasAssociation($fieldName) &&
368
                $meta->isSingleValuedAssociation($fieldName) &&
369
                $this->configuration->isAuditedField($entity, $fieldName)
370
            ) {
371
                $o = $this->assoc($em, $old);
372
                $n = $this->assoc($em, $new);
373
                if ($o !== $n) {
374
                    $diff[$fieldName] = [
375
                        'old' => $o,
376
                        'new' => $n,
377
                    ];
378
                }
379
            }
380
        }
381
382
        return $diff;
383
    }
384
385
    /**
386
     * Returns an array describing an association.
387
     *
388
     * @param EntityManager $em
389
     * @param object        $association
390
     * @param mixed         $id
391
     *
392
     * @throws \Doctrine\DBAL\DBALException
393
     * @throws \Doctrine\ORM\Mapping\MappingException
394
     *
395
     * @return array
396
     */
397
    private function assoc(EntityManager $em, $association = null, $id = null): ?array
398
    {
399
        if (null === $association) {
400
            return null;
401
        }
402
403
        $em->getUnitOfWork()->initializeObject($association); // ensure that proxies are initialized
404
        $meta = $em->getClassMetadata(\get_class($association));
405
        $pkName = $meta->getSingleIdentifierFieldName();
406
        $pkValue = $id ?? $this->id($em, $association);
407
        if (method_exists($association, '__toString')) {
408
            $label = (string) $association;
409
        } else {
410
            $label = \get_class($association).'#'.$pkValue;
411
        }
412
413
        return [
414
            'label' => $label,
415
            'class' => $meta->name,
416
            'table' => $meta->table['name'],
417
            $pkName => $pkValue,
418
        ];
419
    }
420
421
    /**
422
     * Type converts the input value and returns it.
423
     *
424
     * @param EntityManager $em
425
     * @param Type          $type
426
     * @param mixed         $value
427
     *
428
     * @throws \Doctrine\DBAL\DBALException
429
     *
430
     * @return mixed
431
     */
432
    private function value(EntityManager $em, Type $type, $value)
433
    {
434
        if (null === $value) {
435
            return null;
436
        }
437
438
        $platform = $em->getConnection()->getDatabasePlatform();
439
440
        switch ($type->getName()) {
441
            case Type::DECIMAL:
442
            case Type::BIGINT:
443
                $convertedValue = (string) $value;
444
445
                break;
446
            case Type::INTEGER:
447
            case Type::SMALLINT:
448
                $convertedValue = (int) $value;
449
450
                break;
451
            case Type::FLOAT:
452
            case Type::BOOLEAN:
453
                $convertedValue = $type->convertToPHPValue($value, $platform);
454
455
                break;
456
            default:
457
                $convertedValue = $type->convertToDatabaseValue($value, $platform);
458
        }
459
460
        return $convertedValue;
461
    }
462
463
    /**
464
     * Blames an audit operation.
465
     *
466
     * @return array
467
     */
468
    private function blame(): array
469
    {
470
        $user_id = null;
471
        $username = null;
472
        $client_ip = null;
473
474
        $request = $this->configuration->getRequestStack()->getCurrentRequest();
475
        if (null !== $request) {
476
            $client_ip = $request->getClientIp();
477
        }
478
479
        $user = $this->configuration->getUserProvider()->getUser();
480
        if ($user instanceof UserInterface) {
481
            $user_id = $user->getId();
482
            $username = $user->getUsername();
483
        }
484
485
        return [
486
            'user_id' => $user_id,
487
            'username' => $username,
488
            'client_ip' => $client_ip,
489
        ];
490
    }
491
492
    /**
493
     * {@inheritdoc}
494
     */
495
    public function getSubscribedEvents(): array
496
    {
497
        return [Events::onFlush, 'preSoftDelete'];
498
    }
499
500
    /**
501
     * @param \Doctrine\ORM\UnitOfWork $uow
502
     */
503
    private function collectScheduledInsertions(\Doctrine\ORM\UnitOfWork $uow): void
504
    {
505
        foreach ($uow->getScheduledEntityInsertions() as $entity) {
506
            if ($this->configuration->isAudited($entity)) {
507
                $this->inserted[] = [
508
                    $entity,
509
                    $uow->getEntityChangeSet($entity),
510
                ];
511
            }
512
        }
513
    }
514
515
    /**
516
     * @param \Doctrine\ORM\UnitOfWork $uow
517
     */
518
    private function collectScheduledUpdates(\Doctrine\ORM\UnitOfWork $uow): void
519
    {
520
        foreach ($uow->getScheduledEntityUpdates() as $entity) {
521
            if ($this->configuration->isAudited($entity)) {
522
                $this->updated[] = [
523
                    $entity,
524
                    $uow->getEntityChangeSet($entity),
525
                ];
526
            }
527
        }
528
    }
529
530
    /**
531
     * @param \Doctrine\ORM\UnitOfWork $uow
532
     * @param EntityManager $em
533
     *
534
     * @throws \Doctrine\DBAL\DBALException
535
     * @throws \Doctrine\ORM\Mapping\MappingException
536
     */
537
    private function collectScheduledDeletions(\Doctrine\ORM\UnitOfWork $uow, EntityManager $em): void
538
    {
539
        foreach ($uow->getScheduledEntityDeletions() as $entity) {
540
            if ($this->configuration->isAudited($entity)) {
541
                $uow->initializeObject($entity);
542
                $this->removed[] = [
543
                    $entity,
544
                    $this->id($em, $entity),
545
                ];
546
            }
547
        }
548
    }
549
550
    /**
551
     * @param \Doctrine\ORM\UnitOfWork $uow
552
     * @param EntityManager $em
553
     *
554
     * @throws \Doctrine\DBAL\DBALException
555
     * @throws \Doctrine\ORM\Mapping\MappingException
556
     */
557
    private function collectScheduledCollectionUpdates(\Doctrine\ORM\UnitOfWork $uow, EntityManager $em): void
558
    {
559
        foreach ($uow->getScheduledCollectionUpdates() as $collection) {
560
            if ($this->configuration->isAudited($collection->getOwner())) {
561
                $mapping = $collection->getMapping();
562
                foreach ($collection->getInsertDiff() as $entity) {
563
                    if ($this->configuration->isAudited($entity)) {
564
                        $this->associated[] = [
565
                            $collection->getOwner(),
566
                            $entity,
567
                            $mapping,
568
                        ];
569
                    }
570
                }
571
                foreach ($collection->getDeleteDiff() as $entity) {
572
                    if ($this->configuration->isAudited($entity)) {
573
                        $this->dissociated[] = [
574
                            $collection->getOwner(),
575
                            $entity,
576
                            $this->id($em, $entity),
577
                            $mapping,
578
                        ];
579
                    }
580
                }
581
            }
582
        }
583
    }
584
585
    /**
586
     * @param \Doctrine\ORM\UnitOfWork $uow
587
     * @param EntityManager $em
588
     *
589
     * @throws \Doctrine\DBAL\DBALException
590
     * @throws \Doctrine\ORM\Mapping\MappingException
591
     */
592
    private function collectScheduledCollectionDeletions(\Doctrine\ORM\UnitOfWork $uow, EntityManager $em): void
593
    {
594
        foreach ($uow->getScheduledCollectionDeletions() as $collection) {
595
            if ($this->configuration->isAudited($collection->getOwner())) {
596
                $mapping = $collection->getMapping();
597
                foreach ($collection->toArray() as $entity) {
598
                    if (!$this->configuration->isAudited($entity)) {
599
                        continue;
600
                    }
601
                    $this->dissociated[] = [
602
                        $collection->getOwner(),
603
                        $entity,
604
                        $this->id($em, $entity),
605
                        $mapping,
606
                    ];
607
                }
608
            }
609
        }
610
    }
611
612
    /**
613
     * @param EntityManager $em
614
     * @param \Doctrine\ORM\UnitOfWork $uow
615
     *
616
     * @throws \Doctrine\DBAL\DBALException
617
     * @throws \Doctrine\ORM\Mapping\MappingException
618
     */
619
    private function processInsertions(EntityManager $em, \Doctrine\ORM\UnitOfWork $uow): void
620
    {
621
        foreach ($this->inserted as list($entity, $ch)) {
622
            // the changeset might be updated from UOW extra updates
623
            $ch = array_merge($ch, $uow->getEntityChangeSet($entity));
624
            $this->insert($em, $entity, $ch);
625
        }
626
    }
627
628
    /**
629
     * @param EntityManager $em
630
     * @param \Doctrine\ORM\UnitOfWork $uow
631
     *
632
     * @throws \Doctrine\DBAL\DBALException
633
     * @throws \Doctrine\ORM\Mapping\MappingException
634
     */
635
    private function processUpdates(EntityManager $em, \Doctrine\ORM\UnitOfWork $uow): void
636
    {
637
        foreach ($this->updated as list($entity, $ch)) {
638
            // the changeset might be updated from UOW extra updates
639
            $ch = array_merge($ch, $uow->getEntityChangeSet($entity));
640
            $this->update($em, $entity, $ch);
641
        }
642
    }
643
644
    /**
645
     * @param EntityManager $em
646
     *
647
     * @throws \Doctrine\DBAL\DBALException
648
     * @throws \Doctrine\ORM\Mapping\MappingException
649
     */
650
    private function processAssociations(EntityManager $em): void
651
    {
652
        foreach ($this->associated as list($source, $target, $mapping)) {
653
            $this->associate($em, $source, $target, $mapping);
654
        }
655
    }
656
657
    /**
658
     * @param EntityManager $em
659
     *
660
     * @throws \Doctrine\DBAL\DBALException
661
     * @throws \Doctrine\ORM\Mapping\MappingException
662
     */
663
    private function processDissociations(EntityManager $em): void
664
    {
665
        foreach ($this->dissociated as list($source, $target, $id, $mapping)) {
666
            $this->dissociate($em, $source, $target, $mapping);
667
        }
668
    }
669
670
    /**
671
     * @param EntityManager $em
672
     *
673
     * @throws \Doctrine\DBAL\DBALException
674
     * @throws \Doctrine\ORM\Mapping\MappingException
675
     */
676
    private function processDeletions(EntityManager $em): void
677
    {
678
        foreach ($this->removed as list($entity, $id)) {
679
            $this->remove($em, $entity, $id);
680
        }
681
    }
682
}
683