├── src
├── Resources
│ ├── views
│ │ ├── layout.html.twig
│ │ └── Audit
│ │ │ ├── index.html.twig
│ │ │ ├── view_revision.html.twig
│ │ │ ├── view_detail.html.twig
│ │ │ ├── compare.html.twig
│ │ │ └── view_entity.html.twig
│ └── config
│ │ ├── routing
│ │ ├── audit.xml
│ │ └── audit.php
│ │ ├── routing.yml
│ │ ├── actions.php
│ │ └── auditable.php
├── SimpleThingsEntityAuditBundle.php
├── Exception
│ ├── AuditedCollectionException.php
│ ├── NotAuditedException.php
│ ├── InvalidRevisionException.php
│ ├── DeletedException.php
│ ├── AuditException.php
│ └── NoRevisionFoundException.php
├── Utils
│ ├── DbalCompatibilityTrait.php
│ ├── SQLResultCasing.php
│ ├── SimpleDiff.php
│ ├── ArrayDiff.php
│ └── ORMCompatibilityTrait.php
├── EventListener
│ ├── CacheListener.php
│ ├── CreateSchemaListener.php
│ └── LogRevisionsListener.php
├── Action
│ ├── IndexAction.php
│ ├── ViewEntityAction.php
│ ├── ViewRevisionAction.php
│ ├── ViewDetailAction.php
│ └── CompareAction.php
├── Revision.php
├── Metadata
│ └── MetadataFactory.php
├── ChangedEntity.php
├── AuditManager.php
├── DeferredChangedManyToManyEntityRevisionToPersist.php
├── User
│ └── TokenStorageUsernameCallable.php
├── DependencyInjection
│ ├── SimpleThingsEntityAuditExtension.php
│ └── Configuration.php
├── Controller
│ └── AuditController.php
├── AuditConfiguration.php
├── Collection
│ └── AuditedCollection.php
└── AuditReader.php
├── LICENSE
└── composer.json
/src/Resources/views/layout.html.twig:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {% block simplethings_entityaudit_content '' %}
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/Resources/config/routing/audit.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/Resources/views/Audit/index.html.twig:
--------------------------------------------------------------------------------
1 | {% extends "@SimpleThingsEntityAudit/layout.html.twig" %}
2 |
3 | {% block simplethings_entityaudit_content %}
4 | Revisions
5 |
6 |
11 | {% endblock simplethings_entityaudit_content %}
12 |
--------------------------------------------------------------------------------
/src/SimpleThingsEntityAuditBundle.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit;
15 |
16 | use Symfony\Component\HttpKernel\Bundle\Bundle;
17 |
18 | /**
19 | * NEXT_MAJOR: Declare the class as final.
20 | *
21 | * @final since 1.19.0
22 | */
23 | class SimpleThingsEntityAuditBundle extends Bundle
24 | {
25 | }
26 |
--------------------------------------------------------------------------------
/src/Exception/AuditedCollectionException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\Exception;
15 |
16 | /**
17 | * NEXT_MAJOR: Declare the class as final.
18 | *
19 | * @final since 1.19.0
20 | */
21 | class AuditedCollectionException extends AuditException
22 | {
23 | public function __construct(string $message)
24 | {
25 | parent::__construct(null, null, null, $message);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/Exception/NotAuditedException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\Exception;
15 |
16 | /**
17 | * NEXT_MAJOR: Declare the class as final.
18 | *
19 | * @final since 1.19.0
20 | */
21 | class NotAuditedException extends AuditException
22 | {
23 | public function __construct(string $className)
24 | {
25 | parent::__construct($className, null, null, \sprintf('Class "%s" is not audited.', $className));
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/Exception/InvalidRevisionException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\Exception;
15 |
16 | /**
17 | * NEXT_MAJOR: Declare the class as final.
18 | *
19 | * @final since 1.19.0
20 | */
21 | class InvalidRevisionException extends AuditException
22 | {
23 | /**
24 | * @param int|string $revision
25 | */
26 | public function __construct($revision)
27 | {
28 | parent::__construct(null, null, $revision, \sprintf('No revision "%s" exists.', $revision));
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/Resources/views/Audit/view_revision.html.twig:
--------------------------------------------------------------------------------
1 | {% extends "@SimpleThingsEntityAudit/layout.html.twig" %}
2 |
3 | {% block simplethings_entityaudit_content %}
4 | Entities changed in revision {{ revision.rev }}
5 |
6 | Home
7 |
8 |
9 |
10 | Class Name
11 | Identifiers
12 | Revision Type
13 |
14 |
15 | {% for changedEntity in changedEntities %}
16 |
17 | {{ changedEntity.className }}
18 | {{ changedEntity.id | join(', ') }}
19 | {{ changedEntity.revisionType }}
20 |
21 | {% endfor %}
22 |
23 |
24 |
25 | {% endblock simplethings_entityaudit_content %}
26 |
--------------------------------------------------------------------------------
/src/Exception/DeletedException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\Exception;
15 |
16 | /**
17 | * NEXT_MAJOR: Declare the class as final.
18 | *
19 | * @final since 1.19.0
20 | */
21 | class DeletedException extends AuditException
22 | {
23 | /**
24 | * @param array $id
25 | * @param int|string $revision
26 | */
27 | public function __construct(string $className, array $id, $revision)
28 | {
29 | parent::__construct($className, $id, $revision, \sprintf(
30 | 'Class "%s" entity id "%s" has been removed at revision %s',
31 | $className,
32 | implode(', ', $id),
33 | $revision
34 | ));
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Utils/DbalCompatibilityTrait.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\Utils;
15 |
16 | use Doctrine\DBAL\ParameterType;
17 | use Doctrine\DBAL\Schema\Name\UnqualifiedName;
18 |
19 | /**
20 | * NEXT_MAJOR: remove this trait and all `if` blocks.
21 | *
22 | * @internal
23 | */
24 | trait DbalCompatibilityTrait
25 | {
26 | protected function isDbal4_3(): bool
27 | {
28 | return class_exists(UnqualifiedName::class); // @phpstan-ignore-line class added in doctrine/dbal 4.3
29 | }
30 |
31 | protected function isDbal4(): bool
32 | {
33 | return is_subclass_of(ParameterType::class, \UnitEnum::class); // @phpstan-ignore-line class changed to enum in doctrine/dbal 4
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/Resources/config/routing.yml:
--------------------------------------------------------------------------------
1 | # NEXT_MAJOR: remove this file and add upgrade note
2 | simple_things_entity_audit_home:
3 | path: /{page}
4 | controller: SimpleThings\EntityAudit\Action\IndexAction
5 | defaults:
6 | page: 1
7 | requirements:
8 | page: \d+
9 |
10 | simple_things_entity_audit_viewrevision:
11 | path: /viewrev/{rev}
12 | controller: SimpleThings\EntityAudit\Action\ViewRevisionAction
13 | requirements:
14 | rev: \d+
15 |
16 | simple_things_entity_audit_viewentity_detail:
17 | path: /viewent/{className}/{id}/{rev}
18 | controller: SimpleThings\EntityAudit\Action\ViewDetailAction
19 | requirements:
20 | rev: \d+
21 |
22 | simple_things_entity_audit_viewentity:
23 | path: /viewent/{className}/{id}
24 | controller: SimpleThings\EntityAudit\Action\ViewEntityAction
25 |
26 | simple_things_entity_audit_compare:
27 | path: /compare/{className}/{id}/{oldRev}/{newRev}
28 | controller: SimpleThings\EntityAudit\Action\CompareAction
29 | defaults:
30 | oldRev: ~
31 | newRev: ~
32 |
--------------------------------------------------------------------------------
/src/Exception/AuditException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\Exception;
15 |
16 | abstract class AuditException extends \Exception
17 | {
18 | /**
19 | * @var string|null
20 | */
21 | protected $className;
22 |
23 | /**
24 | * @var array|null
25 | */
26 | protected $id;
27 |
28 | /**
29 | * @param array|null $id
30 | * @param int|string|null $revision
31 | */
32 | public function __construct(
33 | ?string $className,
34 | ?array $id,
35 | protected $revision,
36 | string $message = '',
37 | ) {
38 | parent::__construct($message);
39 |
40 | $this->className = $className;
41 | $this->id = $id;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/EventListener/CacheListener.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\EventListener;
15 |
16 | use Doctrine\Common\EventSubscriber;
17 | use Doctrine\ORM\Events;
18 | use SimpleThings\EntityAudit\AuditReader;
19 |
20 | /**
21 | * NEXT_MAJOR: do not implement EventSubscriber interface anymore.
22 | */
23 | final class CacheListener implements EventSubscriber
24 | {
25 | public function __construct(private AuditReader $auditReader)
26 | {
27 | }
28 |
29 | /**
30 | * NEXT_MAJOR: remove this method.
31 | */
32 | public function getSubscribedEvents(): array
33 | {
34 | return [Events::onClear];
35 | }
36 |
37 | public function onClear(): void
38 | {
39 | $this->auditReader->clearEntityCache();
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/Action/IndexAction.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\Action;
15 |
16 | use SimpleThings\EntityAudit\AuditReader;
17 | use Symfony\Component\HttpFoundation\Response;
18 | use Twig\Environment;
19 |
20 | final class IndexAction
21 | {
22 | public function __construct(
23 | private Environment $twig,
24 | private AuditReader $auditReader,
25 | ) {
26 | }
27 |
28 | public function __invoke(int $page = 1): Response
29 | {
30 | $revisions = $this->auditReader->findRevisionHistory(20, 20 * ($page - 1));
31 |
32 | $content = $this->twig->render('@SimpleThingsEntityAudit/Audit/index.html.twig', [
33 | 'revisions' => $revisions,
34 | ]);
35 |
36 | return new Response($content);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Exception/NoRevisionFoundException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\Exception;
15 |
16 | /**
17 | * NEXT_MAJOR: Declare the class as final.
18 | *
19 | * @final since 1.19.0
20 | */
21 | class NoRevisionFoundException extends AuditException
22 | {
23 | /**
24 | * @param array $id
25 | * @param int|string $revision
26 | */
27 | public function __construct(string $className, array $id, $revision)
28 | {
29 | parent::__construct($className, $id, $revision, \sprintf(
30 | 'No revision of class "%s" (%s) was found at revision %s or before. The entity did not exist at the specified revision yet.',
31 | $className,
32 | implode(', ', $id),
33 | $revision
34 | ));
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Resources/views/Audit/view_detail.html.twig:
--------------------------------------------------------------------------------
1 | {% extends "@SimpleThingsEntityAudit/layout.html.twig" %}
2 |
3 | {% block simplethings_entityaudit_content %}
4 | Detail of {{ className }} with identifiers of {{ id }} at revisions {{ rev }}
5 |
6 | Compare revisions
7 |
8 |
9 | Field
10 | Value
11 |
12 |
13 | {% for field, value in data %}
14 |
15 | {{ field }}
16 | {% if value.timestamp is defined %}
17 | {{ value|date('m/d/Y') }}
18 | {% elseif value is iterable %}
19 |
20 |
21 | {% for element in value %}
22 | {{ element }}
23 | {% endfor %}
24 |
25 |
26 | {% else %}
27 | {{ value }}
28 | {% endif %}
29 |
30 | {% endfor %}
31 |
32 |
33 |
34 | {% endblock simplethings_entityaudit_content %}
35 |
36 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2010 Thomas Rabaix
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/Action/ViewEntityAction.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\Action;
15 |
16 | use SimpleThings\EntityAudit\AuditReader;
17 | use Symfony\Component\HttpFoundation\Response;
18 | use Twig\Environment;
19 |
20 | final class ViewEntityAction
21 | {
22 | public function __construct(
23 | private Environment $twig,
24 | private AuditReader $auditReader,
25 | ) {
26 | }
27 |
28 | /**
29 | * @phpstan-param class-string $className
30 | */
31 | public function __invoke(string $className, string $id): Response
32 | {
33 | $revisions = $this->auditReader->findRevisions($className, $id);
34 |
35 | $content = $this->twig->render('@SimpleThingsEntityAudit/Audit/view_entity.html.twig', [
36 | 'id' => $id,
37 | 'className' => $className,
38 | 'revisions' => $revisions,
39 | ]);
40 |
41 | return new Response($content);
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Revision.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit;
15 |
16 | /**
17 | * Revision is returned from {@link AuditReader::getRevisions()}.
18 | *
19 | * NEXT_MAJOR: Declare the class as final.
20 | *
21 | * @final since 1.19.0
22 | */
23 | class Revision
24 | {
25 | /**
26 | * @param int|string $rev
27 | */
28 | public function __construct(
29 | private $rev,
30 | private \DateTime $timestamp,
31 | private ?string $username,
32 | ) {
33 | }
34 |
35 | /**
36 | * @return int|string
37 | */
38 | public function getRev()
39 | {
40 | return $this->rev;
41 | }
42 |
43 | /**
44 | * @return \DateTime
45 | */
46 | public function getTimestamp()
47 | {
48 | return $this->timestamp;
49 | }
50 |
51 | /**
52 | * @return string|null
53 | */
54 | public function getUsername()
55 | {
56 | return $this->username;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/Resources/views/Audit/compare.html.twig:
--------------------------------------------------------------------------------
1 | {% extends "@SimpleThingsEntityAudit/layout.html.twig" %}
2 |
3 | {% macro showValue(value) %}
4 | {% if value.timestamp is defined %}
5 | {{ value|date('m/d/Y') }}
6 | {% elseif value is iterable %}
7 |
8 | {% for element in value %}
9 | {{ element }}
10 | {% endfor %}
11 |
12 | {% else %}
13 | {{ value }}
14 | {% endif %}
15 | {% endmacro %}
16 |
17 | {% import _self as helper %}
18 |
19 | {% block simplethings_entityaudit_content %}
20 | Comparing {{ className }} with identifiers of {{ id }} between revisions {{ oldRev }} and {{ newRev }}
21 |
22 |
23 |
24 | Field
25 | Deleted
26 | Same
27 | Updated
28 |
29 |
30 | {% for field, value in diff %}
31 |
32 | {{ field }}
33 |
34 | {{ helper.showValue(value.old) }}
35 |
36 |
37 | {{ helper.showValue(value.same) }}
38 |
39 |
40 | {{ helper.showValue(value.new) }}
41 |
42 |
43 | {% endfor %}
44 |
45 |
46 |
47 | {% endblock simplethings_entityaudit_content %}
48 |
--------------------------------------------------------------------------------
/src/Utils/SQLResultCasing.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\Utils;
15 |
16 | use Doctrine\DBAL\Platforms\AbstractPlatform;
17 | use Doctrine\DBAL\Platforms\DB2Platform;
18 | use Doctrine\DBAL\Platforms\OraclePlatform;
19 | use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
20 |
21 | /**
22 | * This trait is a copy of \Doctrine\ORM\Internal\SQLResultCasing.
23 | *
24 | * @see https://github.com/doctrine/orm/blob/8f7701279de84411020304f668cc8b49a5afbf5a/lib/Doctrine/ORM/Internal/SQLResultCasing.php
25 | *
26 | * @internal
27 | */
28 | trait SQLResultCasing
29 | {
30 | private function getSQLResultCasing(AbstractPlatform $platform, string $column): string
31 | {
32 | if ($platform instanceof DB2Platform || $platform instanceof OraclePlatform) {
33 | return strtoupper($column);
34 | }
35 |
36 | if ($platform instanceof PostgreSQLPlatform) {
37 | return strtolower($column);
38 | }
39 |
40 | if (method_exists($platform, 'getSQLResultCasing')) {
41 | return $platform->getSQLResultCasing($column);
42 | }
43 |
44 | return $column;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/Resources/views/Audit/view_entity.html.twig:
--------------------------------------------------------------------------------
1 | {% extends "@SimpleThingsEntityAudit/layout.html.twig" %}
2 |
3 | {% block simplethings_entityaudit_content %}
4 | Change history for {{ className }} with identifiers of {{ id }}
5 |
6 | Home
7 |
8 |
40 |
41 | {% endblock simplethings_entityaudit_content %}
42 |
--------------------------------------------------------------------------------
/src/Metadata/MetadataFactory.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\Metadata;
15 |
16 | /**
17 | * NEXT_MAJOR: Declare the class as final.
18 | *
19 | * @final since 1.19.0
20 | */
21 | class MetadataFactory
22 | {
23 | /**
24 | * @var array
25 | *
26 | * @phpstan-var array
27 | */
28 | private array $auditedEntities = [];
29 |
30 | /**
31 | * @phpstan-param class-string[] $auditedEntities
32 | */
33 | public function __construct(array $auditedEntities)
34 | {
35 | // NEXT_MAJOR: Remove array_filter call.
36 | // @phpstan-ignore-next-line
37 | $this->auditedEntities = array_flip(array_filter($auditedEntities));
38 | }
39 |
40 | /**
41 | * @param string $entity
42 | *
43 | * @return bool
44 | *
45 | * @phpstan-param class-string $entity
46 | */
47 | public function isAudited($entity)
48 | {
49 | return isset($this->auditedEntities[$entity]);
50 | }
51 |
52 | /**
53 | * @return array
54 | *
55 | * @phpstan-return array
56 | */
57 | public function getAllClassNames()
58 | {
59 | return array_flip($this->auditedEntities);
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/Action/ViewRevisionAction.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\Action;
15 |
16 | use SimpleThings\EntityAudit\AuditReader;
17 | use SimpleThings\EntityAudit\Exception\InvalidRevisionException;
18 | use Symfony\Component\HttpFoundation\Response;
19 | use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
20 | use Twig\Environment;
21 |
22 | final class ViewRevisionAction
23 | {
24 | public function __construct(
25 | private Environment $twig,
26 | private AuditReader $auditReader,
27 | ) {
28 | }
29 |
30 | /**
31 | * @throws NotFoundHttpException
32 | */
33 | public function __invoke(int $rev): Response
34 | {
35 | try {
36 | $revision = $this->auditReader->findRevision($rev);
37 | } catch (InvalidRevisionException $ex) {
38 | throw new NotFoundHttpException(\sprintf('Revision %d not found', $rev), $ex);
39 | }
40 |
41 | $changedEntities = $this->auditReader->findEntitiesChangedAtRevision($rev);
42 |
43 | $content = $this->twig->render('@SimpleThingsEntityAudit/Audit/view_revision.html.twig', [
44 | 'revision' => $revision,
45 | 'changedEntities' => $changedEntities,
46 | ]);
47 |
48 | return new Response($content);
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/Action/ViewDetailAction.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\Action;
15 |
16 | use SimpleThings\EntityAudit\AuditReader;
17 | use Symfony\Component\HttpFoundation\Response;
18 | use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
19 | use Twig\Environment;
20 |
21 | final class ViewDetailAction
22 | {
23 | public function __construct(
24 | private Environment $twig,
25 | private AuditReader $auditReader,
26 | ) {
27 | }
28 |
29 | /**
30 | * @phpstan-param class-string $className
31 | */
32 | public function __invoke(string $className, string $id, int $rev): Response
33 | {
34 | $entity = $this->auditReader->find($className, $id, $rev);
35 | if (null === $entity) {
36 | throw new NotFoundHttpException('No revision was found.');
37 | }
38 |
39 | $data = $this->auditReader->getEntityValues($className, $entity);
40 | krsort($data);
41 |
42 | $content = $this->twig->render('@SimpleThingsEntityAudit/Audit/view_detail.html.twig', [
43 | 'id' => $id,
44 | 'rev' => $rev,
45 | 'className' => $className,
46 | 'entity' => $entity,
47 | 'data' => $data,
48 | ]);
49 |
50 | return new Response($content);
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/Action/CompareAction.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\Action;
15 |
16 | use SimpleThings\EntityAudit\AuditReader;
17 | use Symfony\Component\HttpFoundation\Request;
18 | use Symfony\Component\HttpFoundation\Response;
19 | use Twig\Environment;
20 |
21 | final class CompareAction
22 | {
23 | public function __construct(
24 | private Environment $twig,
25 | private AuditReader $auditReader,
26 | ) {
27 | }
28 |
29 | /**
30 | * @phpstan-param class-string $className
31 | */
32 | public function __invoke(Request $request, string $className, string $id, ?int $oldRev = null, ?int $newRev = null): Response
33 | {
34 | if (null === $oldRev) {
35 | $oldRev = $request->query->getInt('oldRev');
36 | }
37 |
38 | if (null === $newRev) {
39 | $newRev = $request->query->getInt('newRev');
40 | }
41 |
42 | $diff = $this->auditReader->diff($className, $id, $oldRev, $newRev);
43 |
44 | $content = $this->twig->render('@SimpleThingsEntityAudit/Audit/compare.html.twig', [
45 | 'className' => $className,
46 | 'id' => $id,
47 | 'oldRev' => $oldRev,
48 | 'newRev' => $newRev,
49 | 'diff' => $diff,
50 | ]);
51 |
52 | return new Response($content);
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/ChangedEntity.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit;
15 |
16 | /**
17 | * @phpstan-template T of object
18 | *
19 | * NEXT_MAJOR: Declare the class as final.
20 | *
21 | * @final since 1.19.0
22 | */
23 | class ChangedEntity
24 | {
25 | /**
26 | * @param array $id
27 | *
28 | * @phpstan-param T $entity
29 | * @phpstan-param class-string $className
30 | */
31 | public function __construct(
32 | private string $className,
33 | private array $id,
34 | private string $revType,
35 | private object $entity,
36 | ) {
37 | }
38 |
39 | /**
40 | * @return string
41 | *
42 | * @phpstan-return class-string
43 | */
44 | public function getClassName()
45 | {
46 | return $this->className;
47 | }
48 |
49 | /**
50 | * @return array
51 | */
52 | public function getId()
53 | {
54 | return $this->id;
55 | }
56 |
57 | /**
58 | * @return string
59 | */
60 | public function getRevisionType()
61 | {
62 | return $this->revType;
63 | }
64 |
65 | /**
66 | * @return object
67 | *
68 | * @phpstan-return T
69 | */
70 | public function getEntity()
71 | {
72 | return $this->entity;
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/Resources/config/actions.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace Symfony\Component\DependencyInjection\Loader\Configurator;
15 |
16 | use SimpleThings\EntityAudit\Action\CompareAction;
17 | use SimpleThings\EntityAudit\Action\IndexAction;
18 | use SimpleThings\EntityAudit\Action\ViewDetailAction;
19 | use SimpleThings\EntityAudit\Action\ViewEntityAction;
20 | use SimpleThings\EntityAudit\Action\ViewRevisionAction;
21 |
22 | return static function (ContainerConfigurator $containerConfigurator): void {
23 | $containerConfigurator->services()
24 |
25 | ->set(CompareAction::class, CompareAction::class)
26 | ->public()
27 | ->args([
28 | service('twig'),
29 | service('simplethings_entityaudit.reader'),
30 | ])
31 |
32 | ->set(IndexAction::class, IndexAction::class)
33 | ->public()
34 | ->args([
35 | service('twig'),
36 | service('simplethings_entityaudit.reader'),
37 | ])
38 |
39 | ->set(ViewDetailAction::class, ViewDetailAction::class)
40 | ->public()
41 | ->args([
42 | service('twig'),
43 | service('simplethings_entityaudit.reader'),
44 | ])
45 |
46 | ->set(ViewEntityAction::class, ViewEntityAction::class)
47 | ->public()
48 | ->args([
49 | service('twig'),
50 | service('simplethings_entityaudit.reader'),
51 | ])
52 |
53 | ->set(ViewRevisionAction::class, ViewRevisionAction::class)
54 | ->public()
55 | ->args([
56 | service('twig'),
57 | service('simplethings_entityaudit.reader'),
58 | ]);
59 | };
60 |
--------------------------------------------------------------------------------
/src/AuditManager.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit;
15 |
16 | use Doctrine\Common\EventManager;
17 | use Doctrine\ORM\EntityManager;
18 | use Psr\Clock\ClockInterface;
19 | use SimpleThings\EntityAudit\EventListener\CreateSchemaListener;
20 | use SimpleThings\EntityAudit\EventListener\LogRevisionsListener;
21 | use SimpleThings\EntityAudit\Metadata\MetadataFactory;
22 |
23 | /**
24 | * Audit Manager grants access to metadata and configuration
25 | * and has a factory method for audit queries.
26 | *
27 | * NEXT_MAJOR: Declare the class as final.
28 | *
29 | * @final since 1.19.0
30 | */
31 | class AuditManager
32 | {
33 | private MetadataFactory $metadataFactory;
34 |
35 | public function __construct(
36 | private AuditConfiguration $config,
37 | private ?ClockInterface $clock = null,
38 | ) {
39 | $this->metadataFactory = $config->createMetadataFactory();
40 | }
41 |
42 | /**
43 | * @return MetadataFactory
44 | */
45 | public function getMetadataFactory()
46 | {
47 | return $this->metadataFactory;
48 | }
49 |
50 | /**
51 | * @return AuditConfiguration
52 | */
53 | public function getConfiguration()
54 | {
55 | return $this->config;
56 | }
57 |
58 | /**
59 | * NEXT_MAJOR: Use `\Doctrine\ORM\EntityManagerInterface` for argument 1.
60 | *
61 | * @return AuditReader
62 | */
63 | public function createAuditReader(EntityManager $em)
64 | {
65 | return new AuditReader($em, $this->config, $this->metadataFactory);
66 | }
67 |
68 | public function registerEvents(EventManager $evm): void
69 | {
70 | $evm->addEventSubscriber(new CreateSchemaListener($this));
71 | $evm->addEventSubscriber(new LogRevisionsListener($this, $this->clock));
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/DeferredChangedManyToManyEntityRevisionToPersist.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit;
15 |
16 | use Doctrine\ORM\Mapping\ClassMetadata;
17 | use Doctrine\ORM\Mapping\ManyToManyOwningSideMapping;
18 |
19 | /**
20 | * @internal
21 | */
22 | final class DeferredChangedManyToManyEntityRevisionToPersist
23 | {
24 | /**
25 | * @param array|ManyToManyOwningSideMapping $assoc
26 | * @param array $entityData
27 | * @param ClassMetadata $class
28 | * @param ClassMetadata $targetClass
29 | */
30 | public function __construct(
31 | private object $entity,
32 | private string $revType,
33 | private array $entityData,
34 | private array|ManyToManyOwningSideMapping $assoc,
35 | private ClassMetadata $class,
36 | private ClassMetadata $targetClass,
37 | ) {
38 | }
39 |
40 | public function getEntity(): object
41 | {
42 | return $this->entity;
43 | }
44 |
45 | public function getRevType(): string
46 | {
47 | return $this->revType;
48 | }
49 |
50 | /**
51 | * @return array
52 | */
53 | public function getEntityData(): array
54 | {
55 | return $this->entityData;
56 | }
57 |
58 | /**
59 | * @return array|ManyToManyOwningSideMapping
60 | */
61 | public function getAssoc(): array|ManyToManyOwningSideMapping
62 | {
63 | return $this->assoc;
64 | }
65 |
66 | /**
67 | * @return ClassMetadata
68 | */
69 | public function getClass(): ClassMetadata
70 | {
71 | return $this->class;
72 | }
73 |
74 | /**
75 | * @return ClassMetadata
76 | */
77 | public function getTargetClass(): ClassMetadata
78 | {
79 | return $this->targetClass;
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/Resources/config/routing/audit.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | use SimpleThings\EntityAudit\Action\CompareAction;
15 | use SimpleThings\EntityAudit\Action\IndexAction;
16 | use SimpleThings\EntityAudit\Action\ViewDetailAction;
17 | use SimpleThings\EntityAudit\Action\ViewEntityAction;
18 | use SimpleThings\EntityAudit\Action\ViewRevisionAction;
19 | use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
20 | use Symfony\Component\Routing\Loader\XmlFileLoader;
21 |
22 | return static function (RoutingConfigurator $routes) {
23 | foreach (debug_backtrace() as $trace) {
24 | if (isset($trace['object'], $trace['args'])
25 | /* @phpstan-ignore class.notFound */
26 | && $trace['object'] instanceof XmlFileLoader
27 | && $trace['args'][0] === __DIR__.'/audit.php'
28 | && $trace['args'][3] === __DIR__.'/audit.xml'
29 | ) {
30 | @trigger_error(
31 | sprintf(
32 | 'The "%s/audit.xml" routing configuration is deprecated since sonata-project/entity-audit-bundle 1.22. Import "audit.php" instead.',
33 | __DIR__,
34 | ),
35 | \E_USER_DEPRECATED
36 | );
37 |
38 | break;
39 | }
40 | }
41 |
42 | $routes->add('simple_things_entity_audit_home', '/{page}')
43 | ->controller(IndexAction::class)
44 | ->defaults(['page' => 1])
45 | ->requirements(['page' => '\d+']);
46 |
47 | $routes->add('simple_things_entity_audit_viewrevision', '/viewrev/{rev}')
48 | ->controller(ViewRevisionAction::class)
49 | ->requirements(['page' => '\d+']);
50 |
51 | $routes->add('simple_things_entity_audit_viewentity_detail', '/viewent/{className}/{id}/{rev}')
52 | ->controller(ViewDetailAction::class)
53 | ->requirements(['page' => '\d+']);
54 |
55 | $routes->add('simple_things_entity_audit_viewentity', '/viewent/{className}/{id}')
56 | ->controller(ViewEntityAction::class);
57 |
58 | $routes->add('simple_things_entity_audit_compare', '/compare/{className}/{id}/{oldRev}/{newRev}')
59 | ->controller(CompareAction::class)
60 | ->defaults(['oldRev' => null, 'newRev' => null]);
61 | };
62 |
--------------------------------------------------------------------------------
/src/User/TokenStorageUsernameCallable.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\User;
15 |
16 | use Symfony\Component\DependencyInjection\Container;
17 | use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
18 |
19 | /**
20 | * NEXT_MAJOR: Declare the class as final.
21 | *
22 | * @final since 1.19.0
23 | */
24 | class TokenStorageUsernameCallable
25 | {
26 | private TokenStorageInterface $tokenStorage;
27 |
28 | /**
29 | * NEXT_MAJOR: remove Container type.
30 | *
31 | * @param Container|TokenStorageInterface $tokenStorageOrContainer
32 | */
33 | public function __construct(object $tokenStorageOrContainer)
34 | {
35 | if ($tokenStorageOrContainer instanceof TokenStorageInterface) {
36 | $this->tokenStorage = $tokenStorageOrContainer;
37 | } elseif ($tokenStorageOrContainer instanceof Container) {
38 | @trigger_error(\sprintf(
39 | 'Passing as argument 1 an instance of "%s" to "%s" is deprecated since'
40 | .' sonata-project/entity-audit-bundle 1.x and will throw an "%s" in version 2.0.'
41 | .' You must pass an instance of "%s" instead.',
42 | Container::class,
43 | __METHOD__,
44 | \TypeError::class,
45 | TokenStorageInterface::class
46 | ), \E_USER_DEPRECATED);
47 |
48 | $tokenStorage = $tokenStorageOrContainer->get('security.token_storage');
49 | \assert($tokenStorage instanceof TokenStorageInterface);
50 | $this->tokenStorage = $tokenStorage;
51 | } else {
52 | throw new \TypeError(\sprintf(
53 | 'Argument 1 passed to "%s()" must be an instance of "%s" or %s, instance of "%s" given.',
54 | __METHOD__,
55 | TokenStorageInterface::class,
56 | Container::class,
57 | $tokenStorageOrContainer::class
58 | ));
59 | }
60 | }
61 |
62 | /**
63 | * @return string|null
64 | */
65 | public function __invoke()
66 | {
67 | $token = $this->tokenStorage->getToken();
68 |
69 | if (null !== $token && null !== $token->getUser()) {
70 | return $token->getUserIdentifier();
71 | }
72 |
73 | return null;
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/Utils/SimpleDiff.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\Utils;
15 |
16 | /**
17 | * Class of the SimpleDiff PHP library by Paul Butler.
18 | *
19 | * @see https://github.com/paulgb/simplediff
20 | *
21 | * NEXT_MAJOR: Declare the class as final.
22 | *
23 | * @final since 1.19.0
24 | */
25 | class SimpleDiff
26 | {
27 | /**
28 | * @param array $old
29 | * @param array $new
30 | *
31 | * @return string[]|array, i: array}>
32 | */
33 | public function diff(array $old, array $new)
34 | {
35 | $maxlen = 0;
36 | $omax = 0;
37 | $nmax = 0;
38 | $matrix = [];
39 |
40 | foreach ($old as $oindex => $ovalue) {
41 | $nkeys = array_keys($new, $ovalue, true);
42 | foreach ($nkeys as $nindex) {
43 | $matrix[$oindex][$nindex] = isset($matrix[$oindex - 1][$nindex - 1]) ?
44 | $matrix[$oindex - 1][$nindex - 1] + 1 : 1;
45 | if ($matrix[$oindex][$nindex] > $maxlen) {
46 | $maxlen = $matrix[$oindex][$nindex];
47 | $omax = $oindex + 1 - $maxlen;
48 | $nmax = $nindex + 1 - $maxlen;
49 | }
50 | }
51 | }
52 | if (0 === $maxlen) {
53 | return [['d' => $old, 'i' => $new]];
54 | }
55 |
56 | return array_merge(
57 | $this->diff(\array_slice($old, 0, $omax), \array_slice($new, 0, $nmax)),
58 | \array_slice($new, $nmax, $maxlen),
59 | $this->diff(\array_slice($old, $omax + $maxlen), \array_slice($new, $nmax + $maxlen))
60 | );
61 | }
62 |
63 | /**
64 | * @param string $old
65 | * @param string $new
66 | *
67 | * @return string
68 | */
69 | public function htmlDiff($old, $new)
70 | {
71 | $ret = '';
72 | $diff = $this->diff(explode(' ', $old), explode(' ', $new));
73 | foreach ($diff as $k) {
74 | if (\is_array($k)) {
75 | $ret .= ([] !== $k['d'] ? ''.implode(' ', $k['d']).' ' : '')
76 | .([] !== $k['i'] ? ''.implode(' ', $k['i']).' ' : '');
77 | } else {
78 | $ret .= $k.' ';
79 | }
80 | }
81 |
82 | return $ret;
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sonata-project/entity-audit-bundle",
3 | "description": "Audit for Doctrine Entities",
4 | "license": "MIT",
5 | "type": "symfony-bundle",
6 | "keywords": [
7 | "Persistence",
8 | "Database",
9 | "Audit"
10 | ],
11 | "require": {
12 | "php": "^8.2",
13 | "doctrine/collections": "^1.8 || ^2.0",
14 | "doctrine/dbal": "^3.6 || ^4.0",
15 | "doctrine/event-manager": "^1.2 || ^2.0",
16 | "doctrine/orm": "^2.14 || ^3.0",
17 | "doctrine/persistence": "^3.0 || ^4.0",
18 | "psr/clock": "^1.0",
19 | "symfony/config": "^6.4 || ^7.3 || ^8.0",
20 | "symfony/dependency-injection": "^6.4 || ^7.3 || ^8.0",
21 | "symfony/http-kernel": "^6.4 || ^7.3 || ^8.0",
22 | "symfony/security-core": "^6.4 || ^7.3 || ^8.0",
23 | "twig/twig": "^3.0"
24 | },
25 | "require-dev": {
26 | "doctrine/doctrine-bundle": "^2.17 || ^3.0",
27 | "doctrine/doctrine-fixtures-bundle": "^4.2",
28 | "friendsofphp/php-cs-fixer": "^3.4",
29 | "gedmo/doctrine-extensions": "^3.15",
30 | "matthiasnoback/symfony-dependency-injection-test": "^6.2",
31 | "phpstan/extension-installer": "^1.1",
32 | "phpstan/phpstan": "^1.0 || ^2.0",
33 | "phpstan/phpstan-doctrine": "^1.3.12 || ^2.0",
34 | "phpstan/phpstan-phpunit": "^1.0 || ^2.0",
35 | "phpstan/phpstan-strict-rules": "^1.0 || ^2.0",
36 | "phpstan/phpstan-symfony": "^1.0 || ^2.0",
37 | "phpunit/phpunit": "^11.5.38 || ^12.3.10",
38 | "rector/rector": "^1.1 || ^2.0",
39 | "symfony/browser-kit": "^6.4 || ^7.3 || ^8.0",
40 | "symfony/cache": "^6.4 || ^7.3 || ^8.0",
41 | "symfony/filesystem": "^6.4 || ^7.3 || ^8.0",
42 | "symfony/framework-bundle": "^6.4 || ^7.3 || ^8.0",
43 | "symfony/http-foundation": "^6.4 || ^7.3 || ^8.0",
44 | "symfony/security-bundle": "^6.4 || ^7.3 || ^8.0",
45 | "symfony/twig-bundle": "^6.4 || ^7.3 || ^8.0",
46 | "symfony/var-dumper": "^6.4 || ^7.3 || ^8.0"
47 | },
48 | "conflict": {
49 | "doctrine/doctrine-bundle": "<2.7",
50 | "gedmo/doctrine-extensions": "<3.7",
51 | "symfony/string": "5.4.0-BETA1 || 5.4.0-RC1"
52 | },
53 | "minimum-stability": "dev",
54 | "prefer-stable": true,
55 | "autoload": {
56 | "psr-4": {
57 | "SimpleThings\\EntityAudit\\": "src"
58 | }
59 | },
60 | "autoload-dev": {
61 | "psr-4": {
62 | "Sonata\\EntityAuditBundle\\Tests\\": "tests"
63 | }
64 | },
65 | "config": {
66 | "allow-plugins": {
67 | "composer/package-versions-deprecated": true,
68 | "phpstan/extension-installer": true
69 | },
70 | "sort-packages": true
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/DependencyInjection/SimpleThingsEntityAuditExtension.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\DependencyInjection;
15 |
16 | use Symfony\Component\Config\FileLocator;
17 | use Symfony\Component\DependencyInjection\ContainerBuilder;
18 | use Symfony\Component\DependencyInjection\Extension\Extension;
19 | use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
20 |
21 | /**
22 | * NEXT_MAJOR: Declare the class as final.
23 | *
24 | * @final since 1.19.0
25 | */
26 | class SimpleThingsEntityAuditExtension extends Extension
27 | {
28 | public function load(array $configs, ContainerBuilder $container): void
29 | {
30 | $config = $this->processConfiguration(new Configuration(), $configs);
31 |
32 | $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
33 | $loader->load('actions.php');
34 | $loader->load('auditable.php');
35 |
36 | $configurables = [
37 | 'connection',
38 | 'entity_manager',
39 | 'audited_entities',
40 | 'table_prefix',
41 | 'table_suffix',
42 | 'revision_field_name',
43 | 'revision_type_field_name',
44 | 'revision_table_name',
45 | 'revision_id_field_type',
46 | 'global_ignore_columns',
47 | 'disable_foreign_keys',
48 | ];
49 |
50 | foreach ($configurables as $key) {
51 | $container->setParameter('simplethings.entityaudit.'.$key, $config[$key]);
52 | }
53 |
54 | foreach ($config['service'] as $key => $service) {
55 | if (null !== $service) {
56 | $container->setAlias('simplethings_entityaudit.'.$key, $service);
57 | }
58 | }
59 |
60 | $this->fixParametersFromDoctrineEventListenerTag($container, [
61 | 'simplethings_entityaudit.log_revisions_listener',
62 | 'simplethings_entityaudit.create_schema_listener',
63 | 'simplethings_entityaudit.cache_listener',
64 | ]);
65 | }
66 |
67 | /**
68 | * @param string[] $definitionNames
69 | */
70 | private function fixParametersFromDoctrineEventListenerTag(ContainerBuilder $container, array $definitionNames): void
71 | {
72 | foreach ($definitionNames as $definitionName) {
73 | $definition = $container->getDefinition($definitionName);
74 | $tags = $definition->getTag('doctrine.event_listener');
75 | $definition->clearTag('doctrine.event_listener');
76 |
77 | foreach ($tags as $attributes) {
78 | if (isset($attributes['connection'])) {
79 | $connection = $container->getParameter('simplethings.entityaudit.connection');
80 | \assert(\is_scalar($connection));
81 |
82 | $attributes['connection'] = (string) $connection;
83 | }
84 | $definition->addTag('doctrine.event_listener', $attributes);
85 | }
86 | }
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/Utils/ArrayDiff.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\Utils;
15 |
16 | /**
17 | * Creates a diff between 2 arrays.
18 | *
19 | * @author Tim Nagel
20 | *
21 | * NEXT_MAJOR: Declare the class as final.
22 | *
23 | * @final since 1.19.0
24 | */
25 | class ArrayDiff
26 | {
27 | /**
28 | * @param mixed[] $oldData
29 | * @param mixed[] $newData
30 | *
31 | * @return array>
32 | *
33 | * @phpstan-return array
34 | */
35 | public function diff($oldData, $newData)
36 | {
37 | $diff = [];
38 |
39 | $keys = array_keys($oldData + $newData);
40 | foreach ($keys as $field) {
41 | $old = \array_key_exists($field, $oldData) ? $oldData[$field] : null;
42 | $new = \array_key_exists($field, $newData) ? $newData[$field] : null;
43 |
44 | // If the values are objects, we will compare them by their properties.
45 | // This is necessary because the strict comparison operator (===) will return false if the objects are not the same instance.
46 | if ((\is_object($old) && \is_object($new) && $this->compareObjects($old, $new)) || ($old === $new)) {
47 | $row = ['old' => '', 'new' => '', 'same' => $old];
48 | } else {
49 | $row = ['old' => $old, 'new' => $new, 'same' => ''];
50 | }
51 |
52 | $diff[$field] = $row;
53 | }
54 |
55 | return $diff;
56 | }
57 |
58 | /**
59 | * Compare the type and the property values of two objects.
60 | * Return true if they are the same, false otherwise.
61 | * If the type is the same and all properties are the same, this will return true, even if they are not the same instance.
62 | * This method is different from comparing two objects using ==,
63 | * because internally the strict comparison operator (===) is used to compare the properties.
64 | *
65 | * @see https://www.php.net/manual/en/language.oop5.object-comparison.php
66 | */
67 | private function compareObjects(object $object1, object $object2): bool
68 | {
69 | // Check if the objects are of the same type.
70 | if ($object1::class !== $object2::class) {
71 | return false;
72 | }
73 |
74 | // Check if all properties are the same.
75 | $obj1Properties = (array) $object1;
76 | $obj2Properties = (array) $object2;
77 | foreach ($obj1Properties as $key => $value) {
78 | if (!\array_key_exists($key, $obj2Properties)) {
79 | return false;
80 | }
81 | if (\is_object($value) && \is_object($obj2Properties[$key])) {
82 | if (!$this->compareObjects($value, $obj2Properties[$key])) {
83 | return false;
84 | }
85 |
86 | continue;
87 | }
88 | if ($value !== $obj2Properties[$key]) {
89 | return false;
90 | }
91 | }
92 |
93 | return true;
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/DependencyInjection/Configuration.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\DependencyInjection;
15 |
16 | use Doctrine\DBAL\Types\Types;
17 | use Symfony\Component\Config\Definition\Builder\TreeBuilder;
18 | use Symfony\Component\Config\Definition\ConfigurationInterface;
19 |
20 | /**
21 | * NEXT_MAJOR: Declare the class as final.
22 | *
23 | * @final since 1.19.0
24 | */
25 | class Configuration implements ConfigurationInterface
26 | {
27 | private const ALLOWED_REVISION_ID_FIELD_TYPES = [
28 | Types::STRING,
29 | Types::INTEGER,
30 | Types::SMALLINT,
31 | Types::BIGINT,
32 | Types::GUID,
33 | ];
34 |
35 | /**
36 | * @return TreeBuilder<'array'>
37 | */
38 | public function getConfigTreeBuilder(): TreeBuilder
39 | {
40 | $builder = new TreeBuilder('simple_things_entity_audit');
41 | $rootNode = $builder->getRootNode();
42 |
43 | $rootNode
44 | ->children()
45 | ->scalarNode('connection')->defaultValue('default')->end()
46 | ->scalarNode('entity_manager')->defaultValue('default')->end()
47 | ->arrayNode('audited_entities')
48 | ->prototype('scalar')->end()
49 | ->end()
50 | ->arrayNode('global_ignore_columns')
51 | ->prototype('scalar')->end()
52 | ->end()
53 | ->scalarNode('table_prefix')->defaultValue('')->end()
54 | ->scalarNode('table_suffix')->defaultValue('_audit')->end()
55 | ->scalarNode('revision_field_name')->defaultValue('rev')->end()
56 | ->scalarNode('revision_type_field_name')->defaultValue('revtype')->end()
57 | ->scalarNode('revision_table_name')->defaultValue('revisions')->end()
58 | ->scalarNode('disable_foreign_keys')->defaultValue(false)->end()
59 | ->scalarNode('revision_id_field_type')
60 | ->defaultValue(Types::INTEGER)
61 | // NEXT_MAJOR: Use enumNode() instead.
62 | ->beforeNormalization()
63 | ->always(static function (?string $value): ?string {
64 | if (null !== $value && !\in_array($value, self::ALLOWED_REVISION_ID_FIELD_TYPES, true)) {
65 | @trigger_error(\sprintf(
66 | 'The value "%s" for the "revision_id_field_type" is deprecated'
67 | .' since sonata-project/entity-audit-bundle 1.3 and will throw an error in version 2.0.'
68 | .' You must pass one of the following values: "%s".',
69 | $value,
70 | implode('", "', self::ALLOWED_REVISION_ID_FIELD_TYPES)
71 | ), \E_USER_DEPRECATED);
72 | }
73 |
74 | return $value;
75 | })
76 | ->end()
77 | ->end()
78 | ->arrayNode('service')
79 | ->addDefaultsIfNotSet()
80 | ->children()
81 | ->scalarNode('username_callable')->defaultValue('simplethings_entityaudit.username_callable.token_storage')->end()
82 | ->end()
83 | ->end()
84 | ->end();
85 |
86 | return $builder;
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/Controller/AuditController.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\Controller;
15 |
16 | use SimpleThings\EntityAudit\Action\CompareAction;
17 | use SimpleThings\EntityAudit\Action\IndexAction;
18 | use SimpleThings\EntityAudit\Action\ViewDetailAction;
19 | use SimpleThings\EntityAudit\Action\ViewEntityAction;
20 | use SimpleThings\EntityAudit\Action\ViewRevisionAction;
21 | use SimpleThings\EntityAudit\AuditManager;
22 | use SimpleThings\EntityAudit\AuditReader;
23 | use Symfony\Bundle\FrameworkBundle\Controller\Controller;
24 | use Symfony\Component\HttpFoundation\Request;
25 | use Symfony\Component\HttpFoundation\Response;
26 | use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
27 |
28 | /**
29 | * Controller for listing auditing information.
30 | *
31 | * @author Tim Nagel
32 | *
33 | * @deprecated since sonata-project/entity-audit-bundle 1.1, will be remove in 2.0.
34 | *
35 | * @final since 1.19.0
36 | *
37 | * NEXT_MAJOR: remove this controller
38 | */
39 | class AuditController extends Controller
40 | {
41 | /**
42 | * Renders a paginated list of revisions.
43 | *
44 | * @param int $page
45 | *
46 | * @return Response
47 | */
48 | public function indexAction($page = 1)
49 | {
50 | $indexAction = new IndexAction($this->get('twig'), $this->getAuditReader());
51 |
52 | return $indexAction($page);
53 | }
54 |
55 | /**
56 | * Shows entities changed in the specified revision.
57 | *
58 | * @param int $rev
59 | *
60 | * @throws NotFoundHttpException
61 | *
62 | * @return Response
63 | */
64 | public function viewRevisionAction($rev)
65 | {
66 | $viewRevisionAction = new ViewRevisionAction($this->get('twig'), $this->getAuditReader());
67 |
68 | return $viewRevisionAction($rev);
69 | }
70 |
71 | /**
72 | * Lists revisions for the supplied entity.
73 | *
74 | * @param string $className
75 | * @param string $id
76 | *
77 | * @return Response
78 | */
79 | public function viewEntityAction($className, $id)
80 | {
81 | $viewEntityAction = new ViewEntityAction($this->get('twig'), $this->getAuditReader());
82 |
83 | return $viewEntityAction($className, $id);
84 | }
85 |
86 | /**
87 | * Shows the data for an entity at the specified revision.
88 | *
89 | * @param string $className
90 | * @param string $id Comma separated list of identifiers
91 | * @param int $rev
92 | *
93 | * @return Response
94 | */
95 | public function viewDetailAction($className, $id, $rev)
96 | {
97 | $viewDetailAction = new ViewDetailAction($this->get('twig'), $this->getAuditReader());
98 |
99 | return $viewDetailAction($className, $id, $rev);
100 | }
101 |
102 | /**
103 | * Compares an entity at 2 different revisions.
104 | *
105 | * @param string $className
106 | * @param string $id Comma separated list of identifiers
107 | * @param int|null $oldRev if null, pulled from the query string
108 | * @param int|null $newRev if null, pulled from the query string
109 | *
110 | * @return Response
111 | */
112 | public function compareAction(Request $request, $className, $id, $oldRev = null, $newRev = null)
113 | {
114 | $compareAction = new CompareAction($this->get('twig'), $this->getAuditReader());
115 |
116 | return $compareAction($request, $className, $id, $oldRev, $newRev);
117 | }
118 |
119 | /**
120 | * @return AuditReader
121 | */
122 | protected function getAuditReader()
123 | {
124 | return $this->get('simplethings_entityaudit.reader');
125 | }
126 |
127 | /**
128 | * @return AuditManager
129 | */
130 | protected function getAuditManager()
131 | {
132 | return $this->get('simplethings_entityaudit.manager');
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/src/Resources/config/auditable.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace Symfony\Component\DependencyInjection\Loader\Configurator;
15 |
16 | use Doctrine\ORM\EntityManager;
17 | use Doctrine\ORM\Events;
18 | use Doctrine\ORM\Tools\ToolEvents;
19 | use Psr\Clock\ClockInterface;
20 | use SimpleThings\EntityAudit\AuditConfiguration;
21 | use SimpleThings\EntityAudit\AuditManager;
22 | use SimpleThings\EntityAudit\AuditReader;
23 | use SimpleThings\EntityAudit\EventListener\CacheListener;
24 | use SimpleThings\EntityAudit\EventListener\CreateSchemaListener;
25 | use SimpleThings\EntityAudit\EventListener\LogRevisionsListener;
26 | use SimpleThings\EntityAudit\User\TokenStorageUsernameCallable;
27 | use Symfony\Component\DependencyInjection\Definition;
28 |
29 | return static function (ContainerConfigurator $containerConfigurator): void {
30 | $containerConfigurator->parameters()
31 |
32 | ->set('simplethings.entityaudit.connection', null)
33 |
34 | ->set('simplethings.entityaudit.entity_manager', null)
35 |
36 | ->set('simplethings.entityaudit.audited_entities', [])
37 |
38 | ->set('simplethings.entityaudit.global_ignore_columns', [])
39 |
40 | ->set('simplethings.entityaudit.table_prefix', null)
41 |
42 | ->set('simplethings.entityaudit.table_suffix', null)
43 |
44 | ->set('simplethings.entityaudit.revision_field_name', null)
45 |
46 | ->set('simplethings.entityaudit.revision_type_field_name', null)
47 |
48 | ->set('simplethings.entityaudit.revision_table_name', null)
49 |
50 | ->set('simplethings.entityaudit.revision_id_field_type', null)
51 |
52 | ->set('simplethings.entityaudit.disable_foreign_keys', null);
53 |
54 | $containerConfigurator->services()
55 | ->set('simplethings_entityaudit.manager', AuditManager::class)
56 | ->public()
57 | ->args([
58 | service('simplethings_entityaudit.config'),
59 | service(ClockInterface::class)->nullOnInvalid(),
60 | ])
61 | ->alias(AuditManager::class, 'simplethings_entityaudit.manager')
62 | ->public()
63 |
64 | ->set('simplethings_entityaudit.reader', AuditReader::class)
65 | ->public()
66 | ->factory([service('simplethings_entityaudit.manager'), 'createAuditReader'])
67 | ->args([
68 | (new InlineServiceConfigurator(new Definition(EntityManager::class)))
69 | ->factory([service('doctrine'), 'getManager'])
70 | ->args([param('simplethings.entityaudit.entity_manager')]),
71 | ])
72 | ->alias(AuditReader::class, 'simplethings_entityaudit.reader')
73 |
74 | ->set('simplethings_entityaudit.log_revisions_listener', LogRevisionsListener::class)
75 | ->tag('doctrine.event_listener', [
76 | 'event' => Events::onFlush,
77 | 'connection' => (string) param('simplethings.entityaudit.connection'),
78 | ])
79 | ->tag('doctrine.event_listener', [
80 | 'event' => Events::postPersist,
81 | 'connection' => (string) param('simplethings.entityaudit.connection'),
82 | ])
83 | ->tag('doctrine.event_listener', [
84 | 'event' => Events::postUpdate,
85 | 'connection' => (string) param('simplethings.entityaudit.connection'),
86 | ])
87 | ->tag('doctrine.event_listener', [
88 | 'event' => Events::postFlush,
89 | 'connection' => (string) param('simplethings.entityaudit.connection'),
90 | ])
91 | ->tag('doctrine.event_listener', [
92 | 'event' => Events::onClear,
93 | 'connection' => (string) param('simplethings.entityaudit.connection'),
94 | ])
95 | ->args([
96 | service('simplethings_entityaudit.manager'),
97 | service(ClockInterface::class)->nullOnInvalid(),
98 | ])
99 |
100 | ->set('simplethings_entityaudit.create_schema_listener', CreateSchemaListener::class)
101 | ->tag('doctrine.event_listener', [
102 | 'event' => ToolEvents::postGenerateSchemaTable,
103 | 'connection' => (string) param('simplethings.entityaudit.connection'),
104 | ])
105 | ->tag('doctrine.event_listener', [
106 | 'event' => ToolEvents::postGenerateSchema,
107 | 'connection' => (string) param('simplethings.entityaudit.connection'),
108 | ])
109 | ->args([service('simplethings_entityaudit.manager')])
110 |
111 | ->set('simplethings_entityaudit.cache_listener', CacheListener::class)
112 | ->tag('doctrine.event_listener', [
113 | 'event' => Events::onClear,
114 | 'connection' => (string) param('simplethings.entityaudit.connection'),
115 | ])
116 | ->args([service('simplethings_entityaudit.reader')])
117 |
118 | ->set('simplethings_entityaudit.username_callable.token_storage', TokenStorageUsernameCallable::class)
119 | ->args([service('security.token_storage')])
120 |
121 | ->set('simplethings_entityaudit.config', AuditConfiguration::class)
122 | ->public()
123 | ->call('setAuditedEntityClasses', [param('simplethings.entityaudit.audited_entities')])
124 | ->call('setDisabledForeignKeys', [param('simplethings.entityaudit.disable_foreign_keys')])
125 | ->call('setGlobalIgnoreColumns', [param('simplethings.entityaudit.global_ignore_columns')])
126 | ->call('setTablePrefix', [param('simplethings.entityaudit.table_prefix')])
127 | ->call('setTableSuffix', [param('simplethings.entityaudit.table_suffix')])
128 | ->call('setRevisionTableName', [param('simplethings.entityaudit.revision_table_name')])
129 | ->call('setRevisionIdFieldType', [param('simplethings.entityaudit.revision_id_field_type')])
130 | ->call('setRevisionFieldName', [param('simplethings.entityaudit.revision_field_name')])
131 | ->call('setRevisionTypeFieldName', [param('simplethings.entityaudit.revision_type_field_name')])
132 | ->call('setUsernameCallable', [service('simplethings_entityaudit.username_callable')])
133 | ->alias(AuditConfiguration::class, 'simplethings_entityaudit.config');
134 | };
135 |
--------------------------------------------------------------------------------
/src/AuditConfiguration.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit;
15 |
16 | use Doctrine\DBAL\Types\Types;
17 | use Doctrine\ORM\Mapping\ClassMetadata;
18 | use SimpleThings\EntityAudit\Metadata\MetadataFactory;
19 |
20 | /**
21 | * NEXT_MAJOR: Declare the class as final.
22 | *
23 | * @final since 1.19.0
24 | */
25 | class AuditConfiguration
26 | {
27 | /**
28 | * @var string[]
29 | *
30 | * @phpstan-var class-string[]
31 | */
32 | private array $auditedEntityClasses = [];
33 |
34 | private bool $disableForeignKeys = false;
35 |
36 | /**
37 | * @var string[]
38 | */
39 | private array $globalIgnoreColumns = [];
40 |
41 | /** @phpstan-var literal-string */
42 | private string $tablePrefix = '';
43 |
44 | /** @phpstan-var literal-string */
45 | private string $tableSuffix = '_audit';
46 |
47 | /** @phpstan-var literal-string */
48 | private string $revisionTableName = 'revisions';
49 |
50 | /** @phpstan-var literal-string */
51 | private string $revisionFieldName = 'rev';
52 |
53 | /** @phpstan-var literal-string */
54 | private string $revisionTypeFieldName = 'revtype';
55 |
56 | private string $revisionIdFieldType = Types::INTEGER;
57 |
58 | /**
59 | * @var callable|null
60 | */
61 | private $usernameCallable;
62 |
63 | /**
64 | * @param string[] $classes
65 | *
66 | * @return AuditConfiguration
67 | *
68 | * @phpstan-param class-string[] $classes
69 | */
70 | public static function forEntities(array $classes)
71 | {
72 | $conf = new self();
73 | $conf->auditedEntityClasses = $classes;
74 |
75 | return $conf;
76 | }
77 |
78 | /**
79 | * @param ClassMetadata $metadata
80 | *
81 | * @return string
82 | *
83 | * @phpstan-return literal-string
84 | */
85 | public function getTableName(ClassMetadata $metadata)
86 | {
87 | /** @var literal-string $tableName */
88 | $tableName = $metadata->getTableName();
89 | /** @var literal-string|null $schemaName */
90 | $schemaName = $metadata->getSchemaName();
91 | if (null !== $schemaName && '' !== $schemaName) {
92 | $tableName = $schemaName.'.'.$tableName;
93 | }
94 |
95 | return $this->getTablePrefix().$tableName.$this->getTableSuffix();
96 | }
97 |
98 | public function areForeignKeysDisabled(): bool
99 | {
100 | return $this->disableForeignKeys;
101 | }
102 |
103 | public function setDisabledForeignKeys(bool $disabled): void
104 | {
105 | $this->disableForeignKeys = $disabled;
106 | }
107 |
108 | /**
109 | * @return string
110 | *
111 | * @phpstan-return literal-string
112 | */
113 | public function getTablePrefix()
114 | {
115 | return $this->tablePrefix;
116 | }
117 |
118 | /**
119 | * @param string $prefix
120 | *
121 | * @phpstan-param literal-string $prefix
122 | */
123 | public function setTablePrefix($prefix): void
124 | {
125 | $this->tablePrefix = $prefix;
126 | }
127 |
128 | /**
129 | * @return string
130 | *
131 | * @phpstan-return literal-string
132 | */
133 | public function getTableSuffix()
134 | {
135 | return $this->tableSuffix;
136 | }
137 |
138 | /**
139 | * @param string $suffix
140 | *
141 | * @phpstan-param literal-string $suffix
142 | */
143 | public function setTableSuffix($suffix): void
144 | {
145 | $this->tableSuffix = $suffix;
146 | }
147 |
148 | /**
149 | * @return string
150 | *
151 | * @phpstan-return literal-string
152 | */
153 | public function getRevisionFieldName()
154 | {
155 | return $this->revisionFieldName;
156 | }
157 |
158 | /**
159 | * @param string $revisionFieldName
160 | *
161 | * @phpstan-param literal-string $revisionFieldName
162 | */
163 | public function setRevisionFieldName($revisionFieldName): void
164 | {
165 | $this->revisionFieldName = $revisionFieldName;
166 | }
167 |
168 | /**
169 | * @return string
170 | *
171 | * @phpstan-return literal-string
172 | */
173 | public function getRevisionTypeFieldName()
174 | {
175 | return $this->revisionTypeFieldName;
176 | }
177 |
178 | /**
179 | * @param string $revisionTypeFieldName
180 | *
181 | * @phpstan-param literal-string $revisionTypeFieldName
182 | */
183 | public function setRevisionTypeFieldName($revisionTypeFieldName): void
184 | {
185 | $this->revisionTypeFieldName = $revisionTypeFieldName;
186 | }
187 |
188 | /**
189 | * @return string
190 | *
191 | * @phpstan-return literal-string
192 | */
193 | public function getRevisionTableName()
194 | {
195 | return $this->revisionTableName;
196 | }
197 |
198 | /**
199 | * @param string $revisionTableName
200 | *
201 | * @phpstan-param literal-string $revisionTableName
202 | */
203 | public function setRevisionTableName($revisionTableName): void
204 | {
205 | $this->revisionTableName = $revisionTableName;
206 | }
207 |
208 | /**
209 | * @param string[] $classes
210 | *
211 | * @phpstan-param class-string[] $classes
212 | */
213 | public function setAuditedEntityClasses(array $classes): void
214 | {
215 | $this->auditedEntityClasses = $classes;
216 | }
217 |
218 | /**
219 | * @return string[]
220 | */
221 | public function getGlobalIgnoreColumns()
222 | {
223 | return $this->globalIgnoreColumns;
224 | }
225 |
226 | /**
227 | * @param string[] $columns
228 | */
229 | public function setGlobalIgnoreColumns(array $columns): void
230 | {
231 | $this->globalIgnoreColumns = $columns;
232 | }
233 |
234 | /**
235 | * @return MetadataFactory
236 | */
237 | public function createMetadataFactory()
238 | {
239 | return new MetadataFactory($this->auditedEntityClasses);
240 | }
241 |
242 | /**
243 | * @deprecated
244 | *
245 | * @param string|null $username
246 | */
247 | public function setCurrentUsername($username): void
248 | {
249 | $this->setUsernameCallable(static fn () => $username);
250 | }
251 |
252 | /**
253 | * @return string
254 | */
255 | public function getCurrentUsername()
256 | {
257 | $callable = $this->usernameCallable;
258 |
259 | return null !== $callable ? (string) $callable() : '';
260 | }
261 |
262 | /**
263 | * @param callable|null $usernameCallable
264 | */
265 | public function setUsernameCallable($usernameCallable): void
266 | {
267 | // php 5.3 compat
268 | if (null !== $usernameCallable && !\is_callable($usernameCallable)) {
269 | throw new \InvalidArgumentException(\sprintf('Username Callable must be callable. Got: %s', get_debug_type($usernameCallable)));
270 | }
271 |
272 | $this->usernameCallable = $usernameCallable;
273 | }
274 |
275 | /**
276 | * @return callable|null
277 | */
278 | public function getUsernameCallable()
279 | {
280 | return $this->usernameCallable;
281 | }
282 |
283 | /**
284 | * @param string $revisionIdFieldType
285 | */
286 | public function setRevisionIdFieldType($revisionIdFieldType): void
287 | {
288 | $this->revisionIdFieldType = $revisionIdFieldType;
289 | }
290 |
291 | /**
292 | * @return string
293 | */
294 | public function getRevisionIdFieldType()
295 | {
296 | return $this->revisionIdFieldType;
297 | }
298 | }
299 |
--------------------------------------------------------------------------------
/src/Utils/ORMCompatibilityTrait.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\Utils;
15 |
16 | use Doctrine\ORM\Mapping\AssociationMapping;
17 | use Doctrine\ORM\Mapping\ClassMetadata;
18 | use Doctrine\ORM\Mapping\DiscriminatorColumnMapping;
19 | use Doctrine\ORM\Mapping\EmbeddedClassMapping;
20 | use Doctrine\ORM\Mapping\FieldMapping;
21 | use Doctrine\ORM\Mapping\JoinColumnMapping;
22 | use Doctrine\ORM\Mapping\ManyToManyOwningSideMapping;
23 | use Doctrine\ORM\Mapping\ToOneOwningSideMapping;
24 |
25 | /**
26 | * @internal
27 | */
28 | trait ORMCompatibilityTrait
29 | {
30 | /**
31 | * @param array|AssociationMapping|EmbeddedClassMapping|FieldMapping|JoinColumnMapping|DiscriminatorColumnMapping $mapping
32 | */
33 | final protected static function getMappingValue(array|AssociationMapping|EmbeddedClassMapping|FieldMapping|JoinColumnMapping|DiscriminatorColumnMapping $mapping, string $key): mixed
34 | {
35 | if ($mapping instanceof AssociationMapping || $mapping instanceof EmbeddedClassMapping || $mapping instanceof FieldMapping || $mapping instanceof JoinColumnMapping || $mapping instanceof DiscriminatorColumnMapping) {
36 | /* @phpstan-ignore property.dynamicName */
37 | return $mapping->$key;
38 | }
39 |
40 | return $mapping[$key] ?? null;
41 | }
42 |
43 | /**
44 | * @param array|AssociationMapping|FieldMapping|DiscriminatorColumnMapping $mapping
45 | *
46 | * @return literal-string
47 | */
48 | final protected static function getMappingFieldNameValue(array|AssociationMapping|EmbeddedClassMapping|FieldMapping|DiscriminatorColumnMapping $mapping): string
49 | {
50 | if ($mapping instanceof AssociationMapping || $mapping instanceof FieldMapping || $mapping instanceof DiscriminatorColumnMapping) {
51 | /* @phpstan-ignore return.type */
52 | return $mapping->fieldName;
53 | }
54 |
55 | /* @phpstan-ignore return.type */
56 | return $mapping['fieldName'];
57 | }
58 |
59 | /**
60 | * @param array|JoinColumnMapping|DiscriminatorColumnMapping $mapping
61 | *
62 | * @return literal-string
63 | */
64 | final protected static function getMappingNameValue(array|JoinColumnMapping|DiscriminatorColumnMapping $mapping): string
65 | {
66 | if ($mapping instanceof JoinColumnMapping || $mapping instanceof DiscriminatorColumnMapping) {
67 | /* @phpstan-ignore return.type */
68 | return $mapping->name;
69 | }
70 |
71 | /* @phpstan-ignore return.type */
72 | return $mapping['name'];
73 | }
74 |
75 | /**
76 | * @param array|FieldMapping $mapping
77 | *
78 | * @return literal-string
79 | */
80 | final protected static function getMappingColumnNameValue(array|FieldMapping $mapping): string
81 | {
82 | if ($mapping instanceof FieldMapping) {
83 | /* @phpstan-ignore return.type */
84 | return $mapping->columnName;
85 | }
86 |
87 | /* @phpstan-ignore return.type */
88 | return $mapping['columnName'];
89 | }
90 |
91 | /**
92 | * @param array|ManyToManyOwningSideMapping $mapping
93 | *
94 | * @return literal-string
95 | */
96 | final protected static function getMappingJoinTableNameValue(array|ManyToManyOwningSideMapping $mapping): string
97 | {
98 | if ($mapping instanceof ManyToManyOwningSideMapping) {
99 | /* @phpstan-ignore return.type */
100 | return $mapping->joinTable->name;
101 | }
102 |
103 | /* @phpstan-ignore return.type */
104 | return $mapping['joinTable']['name'];
105 | }
106 |
107 | /**
108 | * @param array|AssociationMapping $mapping
109 | *
110 | * @phpstan-assert-if-true ManyToManyOwningSideMapping $mapping
111 | */
112 | final protected static function isManyToManyOwningSideMapping(array|AssociationMapping $mapping): bool
113 | {
114 | if ($mapping instanceof AssociationMapping) {
115 | return $mapping->isManyToMany() && $mapping->isOwningSide();
116 | }
117 |
118 | return true === $mapping['isOwningSide'] && ($mapping['type'] & ClassMetadata::MANY_TO_MANY) > 0;
119 | }
120 |
121 | /**
122 | * @param array|AssociationMapping $mapping
123 | *
124 | * @phpstan-assert-if-true ToOneOwningSideMapping $mapping
125 | */
126 | final protected static function isToOneOwningSide(array|AssociationMapping $mapping): bool
127 | {
128 | if ($mapping instanceof AssociationMapping) {
129 | return $mapping->isToOneOwningSide();
130 | }
131 |
132 | return ($mapping['type'] & ClassMetadata::TO_ONE) > 0 && true === $mapping['isOwningSide'];
133 | }
134 |
135 | /**
136 | * @param array|AssociationMapping $mapping
137 | */
138 | final protected static function isToOne(array|AssociationMapping $mapping): bool
139 | {
140 | if ($mapping instanceof AssociationMapping) {
141 | return $mapping->isToOne();
142 | }
143 |
144 | return ($mapping['type'] & ClassMetadata::TO_ONE) > 0;
145 | }
146 |
147 | /**
148 | * @param array|AssociationMapping $mapping
149 | */
150 | final protected static function isManyToMany(array|AssociationMapping $mapping): bool
151 | {
152 | if ($mapping instanceof AssociationMapping) {
153 | return $mapping->isManyToMany();
154 | }
155 |
156 | return ($mapping['type'] & ClassMetadata::MANY_TO_MANY) > 0;
157 | }
158 |
159 | /**
160 | * @param array|ToOneOwningSideMapping $mapping
161 | *
162 | * @return array
163 | */
164 | final protected static function getTargetToSourceKeyColumns(array|ToOneOwningSideMapping $mapping): array
165 | {
166 | if ($mapping instanceof ToOneOwningSideMapping) {
167 | /* @phpstan-ignore return.type */
168 | return $mapping->targetToSourceKeyColumns;
169 | }
170 |
171 | return $mapping['targetToSourceKeyColumns'];
172 | }
173 |
174 | /**
175 | * @param array|ToOneOwningSideMapping $mapping
176 | *
177 | * @return array
178 | */
179 | final protected static function getSourceToTargetKeyColumns(array|ToOneOwningSideMapping $mapping): array
180 | {
181 | if ($mapping instanceof ToOneOwningSideMapping) {
182 | return $mapping->sourceToTargetKeyColumns;
183 | }
184 |
185 | return $mapping['sourceToTargetKeyColumns'];
186 | }
187 |
188 | /**
189 | * @param array|ManyToManyOwningSideMapping $mapping
190 | *
191 | * @return array
192 | */
193 | final protected static function getRelationToSourceKeyColumns(array|ManyToManyOwningSideMapping $mapping): array
194 | {
195 | if ($mapping instanceof ManyToManyOwningSideMapping) {
196 | /* @phpstan-ignore return.type */
197 | return $mapping->relationToSourceKeyColumns;
198 | }
199 |
200 | return $mapping['relationToSourceKeyColumns'];
201 | }
202 |
203 | /**
204 | * @param array|ManyToManyOwningSideMapping $mapping
205 | *
206 | * @return array
207 | */
208 | final protected static function getRelationToTargetKeyColumns(array|ManyToManyOwningSideMapping $mapping): array
209 | {
210 | if ($mapping instanceof ManyToManyOwningSideMapping) {
211 | /* @phpstan-ignore return.type */
212 | return $mapping->relationToTargetKeyColumns;
213 | }
214 |
215 | return $mapping['relationToTargetKeyColumns'];
216 | }
217 |
218 | /**
219 | * @param array|AssociationMapping $mapping
220 | *
221 | * @phpstan-return class-string
222 | */
223 | final protected static function getMappingTargetEntityValue(array|AssociationMapping $mapping): string
224 | {
225 | if ($mapping instanceof AssociationMapping) {
226 | return $mapping->targetEntity;
227 | }
228 |
229 | return $mapping['targetEntity'];
230 | }
231 |
232 | /**
233 | * @param array|AssociationMapping $mapping
234 | */
235 | final protected static function isOwningSide(array|AssociationMapping $mapping): bool
236 | {
237 | if ($mapping instanceof AssociationMapping) {
238 | return $mapping->isOwningSide();
239 | }
240 |
241 | return true === $mapping['isOwningSide'];
242 | }
243 | }
244 |
--------------------------------------------------------------------------------
/src/EventListener/CreateSchemaListener.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\EventListener;
15 |
16 | use Doctrine\Common\EventSubscriber;
17 | use Doctrine\DBAL\Schema\Column;
18 | use Doctrine\DBAL\Schema\Name\Identifier;
19 | use Doctrine\DBAL\Schema\Name\UnqualifiedName;
20 | use Doctrine\DBAL\Schema\PrimaryKeyConstraint;
21 | use Doctrine\DBAL\Schema\Schema;
22 | use Doctrine\DBAL\Schema\Table;
23 | use Doctrine\DBAL\Types\StringType;
24 | use Doctrine\DBAL\Types\Type;
25 | use Doctrine\DBAL\Types\Types;
26 | use Doctrine\ORM\Mapping\ClassMetadata;
27 | use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs;
28 | use Doctrine\ORM\Tools\Event\GenerateSchemaTableEventArgs;
29 | use Doctrine\ORM\Tools\ToolEvents;
30 | use SimpleThings\EntityAudit\AuditConfiguration;
31 | use SimpleThings\EntityAudit\AuditManager;
32 | use SimpleThings\EntityAudit\Metadata\MetadataFactory;
33 | use SimpleThings\EntityAudit\Utils\DbalCompatibilityTrait;
34 | use SimpleThings\EntityAudit\Utils\ORMCompatibilityTrait;
35 |
36 | /**
37 | * NEXT_MAJOR: do not implement EventSubscriber interface anymore.
38 | * NEXT_MAJOR: Declare the class as final.
39 | *
40 | * @final since 1.19.0
41 | */
42 | class CreateSchemaListener implements EventSubscriber
43 | {
44 | use DbalCompatibilityTrait;
45 | use ORMCompatibilityTrait;
46 |
47 | private AuditConfiguration $config;
48 |
49 | private MetadataFactory $metadataFactory;
50 |
51 | /**
52 | * @var string[]
53 | */
54 | private array $defferedJoinTablesToCreate = [];
55 |
56 | public function __construct(AuditManager $auditManager)
57 | {
58 | $this->config = $auditManager->getConfiguration();
59 | $this->metadataFactory = $auditManager->getMetadataFactory();
60 | }
61 |
62 | /**
63 | * NEXT_MAJOR: remove this method.
64 | *
65 | * @return string[]
66 | */
67 | #[\ReturnTypeWillChange]
68 | public function getSubscribedEvents()
69 | {
70 | return [
71 | ToolEvents::postGenerateSchemaTable,
72 | ToolEvents::postGenerateSchema,
73 | ];
74 | }
75 |
76 | public function postGenerateSchemaTable(GenerateSchemaTableEventArgs $eventArgs): void
77 | {
78 | $cm = $eventArgs->getClassMetadata();
79 |
80 | if (!$this->metadataFactory->isAudited($cm->name)) {
81 | $audited = false;
82 | if ($cm->isInheritanceTypeJoined() && $cm->rootEntityName === $cm->name) {
83 | foreach ($cm->subClasses as $subClass) {
84 | if ($this->metadataFactory->isAudited($subClass)) {
85 | $audited = true;
86 |
87 | break;
88 | }
89 | }
90 | }
91 | if (!$audited) {
92 | return;
93 | }
94 | }
95 |
96 | $schema = $eventArgs->getSchema();
97 |
98 | $revisionsTable = $this->createRevisionsTable($schema);
99 |
100 | $entityTable = $eventArgs->getClassTable();
101 | if ($this->isDbal4_3()) {
102 | $tableName = $this->config->getTablePrefix().$entityTable->getObjectName()->toString().$this->config->getTableSuffix();
103 | } else {
104 | $tableName = $this->config->getTablePrefix().$entityTable->getName().$this->config->getTableSuffix(); // @phpstan-ignore-line
105 | }
106 | $revisionTable = $schema->createTable($tableName);
107 |
108 | foreach ($entityTable->getColumns() as $column) {
109 | $this->addColumnToTable($column, $revisionTable);
110 | }
111 | $revisionTable->addColumn($this->config->getRevisionFieldName(), $this->config->getRevisionIdFieldType());
112 | $revisionTable->addColumn($this->config->getRevisionTypeFieldName(), Types::STRING, ['length' => 4]);
113 | if (!\in_array($cm->inheritanceType, [ClassMetadata::INHERITANCE_TYPE_NONE, ClassMetadata::INHERITANCE_TYPE_JOINED, ClassMetadata::INHERITANCE_TYPE_SINGLE_TABLE], true)) {
114 | throw new \RuntimeException(\sprintf('Inheritance type "%s" is not yet supported', $cm->inheritanceType));
115 | }
116 |
117 | if ($this->isDbal4_3()) {
118 | $primaryKey = $entityTable->getPrimaryKeyConstraint();
119 | \assert(null !== $primaryKey);
120 | $pkColumns = $primaryKey->getColumnNames();
121 | $pkColumns[] = new UnqualifiedName(Identifier::unquoted($this->config->getRevisionFieldName())); // @phpstan-ignore-line
122 | $editor = PrimaryKeyConstraint::editor()->setColumnNames(...$pkColumns)->setIsClustered($primaryKey->isClustered());
123 | $revisionTable->addPrimaryKeyConstraint($editor->create());
124 | } else {
125 | $primaryKey = $entityTable->getPrimaryKey();
126 | \assert(null !== $primaryKey);
127 | $pkColumns = $primaryKey->getColumns();
128 | $pkColumns[] = $this->config->getRevisionFieldName();
129 | $revisionTable->setPrimaryKey($pkColumns);
130 | }
131 | if ($this->isDbal4_3()) {
132 | $revIndexName = $this->config->getRevisionFieldName().'_'.md5($revisionTable->getObjectName()->toString()).'_idx';
133 | } else {
134 | $revIndexName = $this->config->getRevisionFieldName().'_'.md5($revisionTable->getName()).'_idx'; // @phpstan-ignore-line
135 | }
136 | $revisionTable->addIndex([$this->config->getRevisionFieldName()], $revIndexName);
137 |
138 | foreach ($cm->associationMappings as $associationMapping) {
139 | if (self::isManyToManyOwningSideMapping($associationMapping)) {
140 | if ($schema->hasTable(self::getMappingJoinTableNameValue($associationMapping))) {
141 | $this->createRevisionJoinTableForJoinTable($schema, self::getMappingJoinTableNameValue($associationMapping));
142 | } else {
143 | $this->defferedJoinTablesToCreate[] = self::getMappingJoinTableNameValue($associationMapping);
144 | }
145 | }
146 | }
147 |
148 | if (!$this->config->areForeignKeysDisabled()) {
149 | $this->createForeignKeys($revisionTable, $revisionsTable);
150 | }
151 | }
152 |
153 | public function postGenerateSchema(GenerateSchemaEventArgs $eventArgs): void
154 | {
155 | $schema = $eventArgs->getSchema();
156 | $this->createRevisionsTable($schema);
157 |
158 | foreach ($this->defferedJoinTablesToCreate as $defferedJoinTableToCreate) {
159 | $this->createRevisionJoinTableForJoinTable($schema, $defferedJoinTableToCreate);
160 | }
161 | }
162 |
163 | private function createForeignKeys(Table $relatedTable, Table $revisionsTable): void
164 | {
165 | if ($this->isDbal4_3()) {
166 | $revisionForeignKeyName = $this->config->getRevisionFieldName().'_'.md5($relatedTable->getObjectName()->toString()).'_fk';
167 | $primaryKey = $revisionsTable->getPrimaryKeyConstraint();
168 | \assert(null !== $primaryKey);
169 | $relatedTable->addForeignKeyConstraint(
170 | $revisionsTable->getObjectName()->toString(),
171 | [$this->config->getRevisionFieldName()],
172 | array_map(static fn (UnqualifiedName $name) => $name->toString(), $primaryKey->getColumnNames()),
173 | [],
174 | $revisionForeignKeyName
175 | );
176 | } elseif ($this->isDbal4()) {
177 | $revisionForeignKeyName = $this->config->getRevisionFieldName().'_'.md5($relatedTable->getName()).'_fk'; // @phpstan-ignore-line
178 | $primaryKey = $revisionsTable->getPrimaryKey();
179 | \assert(null !== $primaryKey);
180 | $relatedTable->addForeignKeyConstraint(
181 | $revisionsTable->getName(), // @phpstan-ignore-line
182 | [$this->config->getRevisionFieldName()],
183 | $primaryKey->getColumns(),
184 | [],
185 | $revisionForeignKeyName
186 | );
187 | } else {
188 | $revisionForeignKeyName = $this->config->getRevisionFieldName().'_'.md5($relatedTable->getName()).'_fk'; // @phpstan-ignore-line
189 | $primaryKey = $revisionsTable->getPrimaryKey();
190 | \assert(null !== $primaryKey);
191 | $relatedTable->addForeignKeyConstraint(
192 | $revisionsTable, // @phpstan-ignore-line doctrine/dbal 3 support for old addForeignKeyConstraint() signature
193 | [$this->config->getRevisionFieldName()],
194 | $primaryKey->getColumns(),
195 | [],
196 | $revisionForeignKeyName
197 | );
198 | }
199 | }
200 |
201 | /**
202 | * Copies $column to another table. All its options are copied but notnull and autoincrement which are set to false.
203 | */
204 | private function addColumnToTable(Column $column, Table $targetTable): void
205 | {
206 | if ($this->isDbal4_3()) {
207 | $columnName = $column->getObjectName()->toString();
208 | } else {
209 | $columnName = $column->getName(); // @phpstan-ignore-line
210 | }
211 |
212 | $targetTable->addColumn(
213 | $columnName,
214 | Type::getTypeRegistry()->lookupName($column->getType())
215 | );
216 |
217 | $targetColumn = $targetTable->getColumn($columnName);
218 | $targetColumn->setLength($column->getLength());
219 | $targetColumn->setPrecision($column->getPrecision());
220 | $targetColumn->setScale($column->getScale());
221 | $targetColumn->setUnsigned($column->getUnsigned());
222 | $targetColumn->setFixed($column->getFixed());
223 | $targetColumn->setDefault($column->getDefault());
224 | if ('' !== $column->getColumnDefinition()) {
225 | $targetColumn->setColumnDefinition($column->getColumnDefinition());
226 | }
227 | $targetColumn->setComment($column->getComment());
228 | if ($this->isDbal4_3()) {
229 | $targetColumn->setPlatformOptions(['charset' => $column->getCharset(), 'collation' => $column->getCollation()]);
230 | } else {
231 | $targetColumn->setPlatformOptions($column->getPlatformOptions());
232 | }
233 |
234 | $targetColumn->setNotnull(false);
235 | $targetColumn->setAutoincrement(false);
236 | }
237 |
238 | private function createRevisionsTable(Schema $schema): Table
239 | {
240 | $revisionsTableName = $this->config->getRevisionTableName();
241 |
242 | if ($schema->hasTable($revisionsTableName)) {
243 | return $schema->getTable($revisionsTableName);
244 | }
245 |
246 | $revisionsTable = $schema->createTable($revisionsTableName);
247 | $revisionsTable->addColumn('id', $this->config->getRevisionIdFieldType(), [
248 | 'autoincrement' => true,
249 | ]);
250 | $revisionsTable->addColumn('timestamp', Types::DATETIME_MUTABLE);
251 | $revisionsTable->addColumn('username', Types::STRING, ['length' => 255])->setNotnull(false);
252 | if ($this->isDbal4_3()) {
253 | $id = new UnqualifiedName(Identifier::unquoted('id'));
254 | $editor = PrimaryKeyConstraint::editor()->setColumnNames($id)->setIsClustered(true);
255 | $revisionsTable->addPrimaryKeyConstraint($editor->create());
256 | } else {
257 | $revisionsTable->setPrimaryKey(['id']);
258 | }
259 |
260 | return $revisionsTable;
261 | }
262 |
263 | private function createRevisionJoinTableForJoinTable(Schema $schema, string $joinTableName): void
264 | {
265 | $joinTable = $schema->getTable($joinTableName);
266 | if ($this->isDbal4_3()) {
267 | $revisionJoinTableName = $this->config->getTablePrefix().$joinTable->getObjectName()->toString().$this->config->getTableSuffix();
268 | } else {
269 | $revisionJoinTableName = $this->config->getTablePrefix().$joinTable->getName().$this->config->getTableSuffix(); // @phpstan-ignore-line
270 | }
271 |
272 | if ($schema->hasTable($revisionJoinTableName)) {
273 | return;
274 | }
275 |
276 | $typeRegistry = Type::getTypeRegistry();
277 | if ($this->isDbal4_3()) {
278 | $tableName = $this->config->getTablePrefix().$joinTable->getObjectName()->toString().$this->config->getTableSuffix();
279 | } else {
280 | $tableName = $this->config->getTablePrefix().$joinTable->getName().$this->config->getTableSuffix(); // @phpstan-ignore-line
281 | }
282 | $revisionJoinTable = $schema->createTable($tableName);
283 | /** @var Column $column */
284 | foreach ($joinTable->getColumns() as $column) {
285 | $options = ['notnull' => false, 'autoincrement' => false];
286 | if ($column->getType() instanceof StringType) {
287 | $options['length'] = $column->getLength();
288 | }
289 | if ($this->isDbal4_3()) {
290 | $revisionJoinTable->addColumn($column->getObjectName()->toString(), $typeRegistry->lookupName($column->getType()), $options);
291 | } else {
292 | $revisionJoinTable->addColumn($column->getName(), $typeRegistry->lookupName($column->getType()), $options); // @phpstan-ignore-line
293 | }
294 | }
295 | $revisionJoinTable->addColumn($this->config->getRevisionFieldName(), $this->config->getRevisionIdFieldType());
296 | $revisionJoinTable->addColumn($this->config->getRevisionTypeFieldName(), 'string', ['length' => 4]);
297 |
298 | if ($this->isDbal4_3()) {
299 | $pk = $joinTable->getPrimaryKeyConstraint();
300 | $pkColumns = null !== $pk ? $pk->getColumnNames() : [];
301 | $pkColumns[] = new UnqualifiedName(Identifier::unquoted($this->config->getRevisionFieldName())); // @phpstan-ignore-line
302 | $editor = PrimaryKeyConstraint::editor()->setColumnNames(...$pkColumns)->setIsClustered(!(null !== $pk) || $pk->isClustered());
303 | $revisionJoinTable->addPrimaryKeyConstraint($editor->create());
304 | } else {
305 | $pk = $joinTable->getPrimaryKey();
306 | $pkColumns = null !== $pk ? $pk->getColumns() : [];
307 | $pkColumns[] = $this->config->getRevisionFieldName();
308 | $revisionJoinTable->setPrimaryKey($pkColumns);
309 | }
310 | if ($this->isDbal4_3()) {
311 | $revIndexName = $this->config->getRevisionFieldName().'_'.md5($revisionJoinTable->getObjectName()->toString()).'_idx';
312 | } else {
313 | $revIndexName = $this->config->getRevisionFieldName().'_'.md5($revisionJoinTable->getName()).'_idx'; // @phpstan-ignore-line
314 | }
315 | $revisionJoinTable->addIndex([$this->config->getRevisionFieldName()], $revIndexName);
316 | }
317 | }
318 |
--------------------------------------------------------------------------------
/src/Collection/AuditedCollection.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\Collection;
15 |
16 | use Doctrine\Common\Collections\ArrayCollection;
17 | use Doctrine\Common\Collections\Collection;
18 | use Doctrine\ORM\Mapping\AssociationMapping;
19 | use Doctrine\ORM\Mapping\ClassMetadata;
20 | use SimpleThings\EntityAudit\AuditConfiguration;
21 | use SimpleThings\EntityAudit\AuditReader;
22 | use SimpleThings\EntityAudit\Exception\AuditedCollectionException;
23 |
24 | /**
25 | * @phpstan-template TKey of array-key
26 | * @phpstan-template T of object
27 | * @phpstan-implements Collection
28 | *
29 | * NEXT_MAJOR: Declare the class as final.
30 | *
31 | * @final since 1.19.0
32 | */
33 | class AuditedCollection implements Collection
34 | {
35 | /**
36 | * @var AuditConfiguration
37 | */
38 | protected $configuration;
39 |
40 | /**
41 | * Entity collection. It can be empty if the collection has not been
42 | * initialized yet or contain identifiers to load the entities.
43 | *
44 | * @var Collection
45 | *
46 | * @phpstan-var Collection, rev: string|int}>
47 | */
48 | protected $entities;
49 |
50 | /**
51 | * Loaded entity collection. It can be empty if the collection has not
52 | * been loaded yet or contain audited entities.
53 | *
54 | * @var Collection
55 | *
56 | * @phpstan-var Collection
57 | */
58 | protected $loadedEntities;
59 |
60 | /**
61 | * @var bool
62 | */
63 | protected $initialized = false;
64 |
65 | /**
66 | * @param string $class
67 | * @param array|AssociationMapping $associationDefinition
68 | * @param array $foreignKeys
69 | * @param string|int $revision
70 | *
71 | * @phpstan-param ClassMetadata $metadata
72 | * @phpstan-param class-string $class
73 | */
74 | public function __construct(
75 | protected AuditReader $auditReader,
76 | protected $class,
77 | protected ClassMetadata $metadata,
78 | protected array|AssociationMapping $associationDefinition,
79 | protected array $foreignKeys,
80 | protected $revision,
81 | ) {
82 | $this->configuration = $auditReader->getConfiguration();
83 | $this->entities = new ArrayCollection();
84 | $this->loadedEntities = new ArrayCollection();
85 | }
86 |
87 | /**
88 | * @return void
89 | */
90 | #[\ReturnTypeWillChange]
91 | public function add(mixed $element)
92 | {
93 | throw new AuditedCollectionException('The AuditedCollection is read-only');
94 | }
95 |
96 | public function clear(): void
97 | {
98 | $this->entities = new ArrayCollection();
99 | $this->loadedEntities = new ArrayCollection();
100 | $this->initialized = false;
101 | }
102 |
103 | /**
104 | * @return bool
105 | */
106 | #[\ReturnTypeWillChange]
107 | public function contains(mixed $element)
108 | {
109 | $this->forceLoad();
110 |
111 | return $this->loadedEntities->contains($element);
112 | }
113 |
114 | /**
115 | * @return bool
116 | *
117 | * @psalm-mutation-free See https://github.com/psalm/psalm-plugin-doctrine/issues/97
118 | */
119 | #[\ReturnTypeWillChange]
120 | public function isEmpty()
121 | {
122 | $this->initialize();
123 |
124 | return $this->entities->isEmpty();
125 | }
126 |
127 | /**
128 | * @param string|int $key
129 | *
130 | * @return T|null
131 | */
132 | #[\ReturnTypeWillChange]
133 | public function remove($key)
134 | {
135 | throw new AuditedCollectionException('Audited collections does not support removal');
136 | }
137 |
138 | /**
139 | * @return bool
140 | */
141 | #[\ReturnTypeWillChange]
142 | public function removeElement(mixed $element)
143 | {
144 | throw new AuditedCollectionException('Audited collections does not support removal');
145 | }
146 |
147 | /**
148 | * @param int|string $key
149 | *
150 | * @return bool
151 | *
152 | * @phpstan-param TKey $key
153 | */
154 | #[\ReturnTypeWillChange]
155 | public function containsKey($key)
156 | {
157 | $this->initialize();
158 |
159 | return $this->entities->containsKey($key);
160 | }
161 |
162 | /**
163 | * @param string|int $key
164 | *
165 | * @return object
166 | *
167 | * @phpstan-param TKey $key
168 | * @phpstan-return T
169 | */
170 | #[\ReturnTypeWillChange]
171 | public function get($key)
172 | {
173 | return $this->offsetGet($key);
174 | }
175 |
176 | /**
177 | * @return array
178 | *
179 | * @phpstan-return list
180 | */
181 | #[\ReturnTypeWillChange]
182 | public function getKeys()
183 | {
184 | $this->initialize();
185 |
186 | return $this->entities->getKeys();
187 | }
188 |
189 | /**
190 | * @return object[]
191 | *
192 | * @phpstan-return list
193 | */
194 | #[\ReturnTypeWillChange]
195 | public function getValues()
196 | {
197 | $this->forceLoad();
198 |
199 | return $this->loadedEntities->getValues();
200 | }
201 |
202 | public function set($key, $value): void
203 | {
204 | throw new AuditedCollectionException('AuditedCollection is read-only');
205 | }
206 |
207 | /**
208 | * @return object[]
209 | *
210 | * @phpstan-return array
211 | */
212 | #[\ReturnTypeWillChange]
213 | public function toArray()
214 | {
215 | $this->forceLoad();
216 |
217 | return $this->loadedEntities->toArray();
218 | }
219 |
220 | /**
221 | * @return object|false
222 | *
223 | * @phpstan-return T|false
224 | */
225 | #[\ReturnTypeWillChange]
226 | public function first()
227 | {
228 | $this->forceLoad();
229 |
230 | return $this->loadedEntities->first();
231 | }
232 |
233 | /**
234 | * @return object|false
235 | *
236 | * @phpstan-return T|false
237 | */
238 | #[\ReturnTypeWillChange]
239 | public function last()
240 | {
241 | $this->forceLoad();
242 |
243 | return $this->loadedEntities->last();
244 | }
245 |
246 | /**
247 | * @return TKey|null
248 | */
249 | #[\ReturnTypeWillChange]
250 | public function key()
251 | {
252 | $this->forceLoad();
253 |
254 | return $this->loadedEntities->key();
255 | }
256 |
257 | /**
258 | * @return object|false
259 | *
260 | * @phpstan-return T|false
261 | */
262 | #[\ReturnTypeWillChange]
263 | public function current()
264 | {
265 | $this->forceLoad();
266 |
267 | return $this->loadedEntities->current();
268 | }
269 |
270 | /**
271 | * @return object|false
272 | *
273 | * @phpstan-return T|false
274 | */
275 | #[\ReturnTypeWillChange]
276 | public function next()
277 | {
278 | $this->forceLoad();
279 |
280 | return $this->loadedEntities->next();
281 | }
282 |
283 | /**
284 | * @return bool
285 | *
286 | * @phpstan-param \Closure(TKey, T):bool $p
287 | */
288 | #[\ReturnTypeWillChange]
289 | public function exists(\Closure $p)
290 | {
291 | $this->forceLoad();
292 |
293 | return $this->loadedEntities->exists($p);
294 | }
295 |
296 | /**
297 | * @return Collection
298 | *
299 | * @phpstan-param \Closure(T, TKey):bool $p
300 | * @phpstan-return Collection
301 | */
302 | #[\ReturnTypeWillChange]
303 | public function filter(\Closure $p)
304 | {
305 | $this->forceLoad();
306 |
307 | return $this->loadedEntities->filter($p);
308 | }
309 |
310 | /**
311 | * @return bool
312 | *
313 | * @phpstan-param \Closure(TKey, T):bool $p
314 | */
315 | #[\ReturnTypeWillChange]
316 | public function forAll(\Closure $p)
317 | {
318 | $this->forceLoad();
319 |
320 | return $this->loadedEntities->forAll($p);
321 | }
322 |
323 | /**
324 | * @phpstan-template U
325 | *
326 | * @return Collection
327 | *
328 | * @phpstan-param \Closure(T):U $func
329 | * @phpstan-return Collection
330 | */
331 | #[\ReturnTypeWillChange]
332 | public function map(\Closure $func)
333 | {
334 | $this->forceLoad();
335 |
336 | return $this->loadedEntities->map($func);
337 | }
338 |
339 | /**
340 | * @return array>
341 | *
342 | * @phpstan-param \Closure(TKey, T):bool $p
343 | * @phpstan-return array{0: Collection, 1: Collection}
344 | */
345 | #[\ReturnTypeWillChange]
346 | public function partition(\Closure $p)
347 | {
348 | $this->forceLoad();
349 |
350 | return $this->loadedEntities->partition($p);
351 | }
352 |
353 | /**
354 | * @return TKey|false
355 | */
356 | #[\ReturnTypeWillChange]
357 | public function indexOf(mixed $element)
358 | {
359 | $this->forceLoad();
360 |
361 | return $this->loadedEntities->indexOf($element);
362 | }
363 |
364 | /**
365 | * @param int $offset
366 | * @param int|null $length
367 | *
368 | * @return object[]
369 | *
370 | * @phpstan-return array
371 | */
372 | #[\ReturnTypeWillChange]
373 | public function slice($offset, $length = null)
374 | {
375 | $this->forceLoad();
376 |
377 | return $this->loadedEntities->slice($offset, $length);
378 | }
379 |
380 | /**
381 | * @return \Traversable
382 | */
383 | #[\ReturnTypeWillChange]
384 | public function getIterator()
385 | {
386 | $this->forceLoad();
387 |
388 | return $this->loadedEntities->getIterator();
389 | }
390 |
391 | /**
392 | * @return bool
393 | */
394 | #[\ReturnTypeWillChange]
395 | public function offsetExists(mixed $offset)
396 | {
397 | $this->forceLoad();
398 |
399 | return $this->loadedEntities->offsetExists($offset);
400 | }
401 |
402 | /**
403 | * @return object
404 | *
405 | * @phpstan-return T
406 | */
407 | #[\ReturnTypeWillChange]
408 | public function offsetGet(mixed $offset)
409 | {
410 | if ($this->loadedEntities->offsetExists($offset)) {
411 | $entity = $this->loadedEntities->offsetGet($offset);
412 | \assert(null !== $entity);
413 |
414 | return $entity;
415 | }
416 |
417 | $this->initialize();
418 |
419 | if (!$this->entities->offsetExists($offset)) {
420 | throw new AuditedCollectionException(\sprintf('Offset "%s" is not defined', $offset));
421 | }
422 |
423 | $entity = $this->entities->offsetGet($offset);
424 | \assert(null !== $entity);
425 | $resolvedEntity = $this->resolve($entity);
426 | $this->loadedEntities->offsetSet($offset, $resolvedEntity);
427 |
428 | return $resolvedEntity;
429 | }
430 |
431 | public function offsetSet(mixed $offset, mixed $value): void
432 | {
433 | throw new AuditedCollectionException('AuditedCollection is read-only');
434 | }
435 |
436 | public function offsetUnset(mixed $offset): void
437 | {
438 | throw new AuditedCollectionException('Audited collections does not support removal');
439 | }
440 |
441 | /**
442 | * @return int<0, max>
443 | */
444 | #[\ReturnTypeWillChange]
445 | public function count()
446 | {
447 | $this->initialize();
448 |
449 | return $this->entities->count();
450 | }
451 |
452 | /**
453 | * @return T|null
454 | *
455 | * @phpstan-return T|null
456 | */
457 | #[\ReturnTypeWillChange]
458 | public function findFirst(\Closure $p)
459 | {
460 | $this->forceLoad();
461 |
462 | return $this->loadedEntities->findFirst($p);
463 | }
464 |
465 | public function reduce(\Closure $func, mixed $initial = null): mixed
466 | {
467 | $this->forceLoad();
468 |
469 | return $this->loadedEntities->reduce($func, $initial);
470 | }
471 |
472 | /**
473 | * @param array{keys: array, rev: string|int} $entity
474 | *
475 | * @return object
476 | *
477 | * @phpstan-return T
478 | */
479 | protected function resolve($entity)
480 | {
481 | $object = $this->auditReader->find(
482 | $this->class,
483 | $entity['keys'],
484 | $this->revision
485 | );
486 |
487 | if (null === $object) {
488 | throw new AuditedCollectionException('Cannot resolve the entity.');
489 | }
490 |
491 | return $object;
492 | }
493 |
494 | protected function forceLoad(): void
495 | {
496 | $this->initialize();
497 |
498 | foreach ($this->entities as $key => $entity) {
499 | if (!$this->loadedEntities->offsetExists($key)) {
500 | $this->loadedEntities->offsetSet($key, $this->resolve($entity));
501 | }
502 | }
503 | }
504 |
505 | protected function initialize(): void
506 | {
507 | if ($this->initialized) {
508 | return;
509 | }
510 |
511 | $params = [];
512 |
513 | $sql = 'SELECT MAX('.$this->configuration->getRevisionFieldName().') as rev, ';
514 | $sql .= implode(', ', $this->metadata->getIdentifierColumnNames()).' ';
515 | if (isset($this->associationDefinition['indexBy'])) {
516 | $sql .= ', '.$this->associationDefinition['indexBy'].' ';
517 | }
518 | $sql .= 'FROM '.$this->configuration->getTableName($this->metadata).' t ';
519 | $sql .= 'WHERE '.$this->configuration->getRevisionFieldName().' <= '.$this->revision.' ';
520 |
521 | foreach ($this->foreignKeys as $column => $value) {
522 | $sql .= 'AND '.$column.' = ? ';
523 | $params[] = $value;
524 | }
525 |
526 | // we check for revisions greater than current belonging to other entities
527 | $sql .= 'AND NOT EXISTS (SELECT * FROM '.$this->configuration->getTableName($this->metadata).' st WHERE';
528 |
529 | // ids
530 | foreach ($this->metadata->getIdentifierColumnNames() as $name) {
531 | $sql .= ' st.'.$name.' = t.'.$name.' AND';
532 | }
533 |
534 | // foreigns
535 | $sql .= ' ((';
536 |
537 | // master entity query, not equals
538 | $notEqualParts = $nullParts = [];
539 | foreach ($this->foreignKeys as $column => $value) {
540 | $notEqualParts[] = $column.' <> ?';
541 | $nullParts[] = $column.' IS NULL';
542 | $params[] = $value;
543 | }
544 |
545 | $sql .= implode(' AND ', $notEqualParts).') OR ('.implode(' AND ', $nullParts).'))';
546 |
547 | // revision
548 | $sql .= ' AND st.'.$this->configuration->getRevisionFieldName().' <= '.$this->revision;
549 | $sql .= ' AND st.'.$this->configuration->getRevisionFieldName().' > t.'.$this->configuration->getRevisionFieldName();
550 |
551 | $sql .= ') ';
552 | // end of check for for belonging to other entities
553 |
554 | // check for deleted revisions older than requested
555 | $sql .= 'AND NOT EXISTS (SELECT * FROM '.$this->configuration->getTableName($this->metadata).' sd WHERE';
556 |
557 | // ids
558 | foreach ($this->metadata->getIdentifierColumnNames() as $name) {
559 | $sql .= ' sd.'.$name.' = t.'.$name.' AND';
560 | }
561 |
562 | // revision
563 | $sql .= ' sd.'.$this->configuration->getRevisionFieldName().' <= '.$this->revision;
564 | $sql .= ' AND sd.'.$this->configuration->getRevisionFieldName().' > t.'.$this->configuration->getRevisionFieldName();
565 |
566 | $sql .= ' AND sd.'.$this->configuration->getRevisionTypeFieldName().' = ?';
567 | $params[] = 'DEL';
568 |
569 | $sql .= ') ';
570 | // end check for deleted revisions older than requested
571 |
572 | $sql .= 'AND '.$this->configuration->getRevisionTypeFieldName().' <> ? ';
573 | $params[] = 'DEL';
574 |
575 | $groupBy = $this->metadata->getIdentifierColumnNames();
576 | if (isset($this->associationDefinition['indexBy'])) {
577 | $groupBy[] = $this->associationDefinition['indexBy'];
578 | }
579 | $sql .= ' GROUP BY '.implode(', ', $groupBy);
580 | $sql .= ' ORDER BY '.implode(' ASC, ', $this->metadata->getIdentifierColumnNames()).' ASC';
581 |
582 | /** @var array> $rows */
583 | $rows = $this->auditReader->getConnection()->fetchAllAssociative($sql, $params);
584 |
585 | foreach ($rows as $row) {
586 | $entity = [
587 | 'rev' => $row['rev'],
588 | ];
589 |
590 | unset($row['rev']);
591 |
592 | $entity['keys'] = $row;
593 |
594 | if (isset($this->associationDefinition['indexBy'])) {
595 | /** @var TKey $key */
596 | $key = $row[$this->associationDefinition['indexBy']];
597 | unset($entity['keys'][$this->associationDefinition['indexBy']]);
598 | $this->entities->offsetSet($key, $entity);
599 | } else {
600 | $this->entities->add($entity);
601 | }
602 | }
603 |
604 | $this->initialized = true;
605 | }
606 | }
607 |
--------------------------------------------------------------------------------
/src/EventListener/LogRevisionsListener.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit\EventListener;
15 |
16 | use Doctrine\Common\EventSubscriber;
17 | use Doctrine\DBAL\Connection;
18 | use Doctrine\DBAL\Exception;
19 | use Doctrine\DBAL\ParameterType;
20 | use Doctrine\DBAL\Types\Type;
21 | use Doctrine\DBAL\Types\Types;
22 | use Doctrine\ORM\EntityManagerInterface;
23 | use Doctrine\ORM\Event\OnFlushEventArgs;
24 | use Doctrine\ORM\Event\PostFlushEventArgs;
25 | use Doctrine\ORM\Event\PostPersistEventArgs;
26 | use Doctrine\ORM\Event\PostUpdateEventArgs;
27 | use Doctrine\ORM\Events;
28 | use Doctrine\ORM\Mapping\ClassMetadata;
29 | use Doctrine\ORM\Mapping\ManyToManyOwningSideMapping;
30 | use Doctrine\ORM\Persisters\Entity\EntityPersister;
31 | use Doctrine\ORM\UnitOfWork;
32 | use Doctrine\ORM\Utility\PersisterHelper;
33 | use Doctrine\Persistence\Mapping\MappingException;
34 | use Psr\Clock\ClockInterface;
35 | use SimpleThings\EntityAudit\AuditConfiguration;
36 | use SimpleThings\EntityAudit\AuditManager;
37 | use SimpleThings\EntityAudit\DeferredChangedManyToManyEntityRevisionToPersist;
38 | use SimpleThings\EntityAudit\Metadata\MetadataFactory;
39 | use SimpleThings\EntityAudit\Utils\DbalCompatibilityTrait;
40 | use SimpleThings\EntityAudit\Utils\ORMCompatibilityTrait;
41 |
42 | /**
43 | * NEXT_MAJOR: do not implement EventSubscriber interface anymore.
44 | * NEXT_MAJOR: Declare the class as final.
45 | *
46 | * @final since 1.19.0
47 | */
48 | class LogRevisionsListener implements EventSubscriber
49 | {
50 | use DbalCompatibilityTrait;
51 | use ORMCompatibilityTrait;
52 |
53 | private AuditConfiguration $config;
54 |
55 | private MetadataFactory $metadataFactory;
56 |
57 | /**
58 | * @var string[]
59 | *
60 | * @phpstan-var array
61 | */
62 | private array $insertRevisionSQL = [];
63 |
64 | /**
65 | * @var string[]
66 | *
67 | * @phpstan-var array
68 | */
69 | private array $insertJoinTableRevisionSQL = [];
70 |
71 | private string|int|null $revisionId = null;
72 |
73 | /**
74 | * @var object[]
75 | *
76 | * @phpstan-var array
77 | */
78 | private array $extraUpdates = [];
79 |
80 | /**
81 | * @var array
82 | */
83 | private array $deferredChangedManyToManyEntityRevisionsToPersist = [];
84 |
85 | public function __construct(
86 | AuditManager $auditManager,
87 | private ?ClockInterface $clock = null,
88 | ) {
89 | $this->config = $auditManager->getConfiguration();
90 | $this->metadataFactory = $auditManager->getMetadataFactory();
91 | }
92 |
93 | /**
94 | * NEXT_MAJOR: remove this method.
95 | *
96 | * @return string[]
97 | */
98 | #[\ReturnTypeWillChange]
99 | public function getSubscribedEvents()
100 | {
101 | return [Events::onFlush, Events::postPersist, Events::postUpdate, Events::postFlush, Events::onClear];
102 | }
103 |
104 | /**
105 | * @throws MappingException
106 | * @throws Exception
107 | * @throws \Exception
108 | */
109 | public function postFlush(PostFlushEventArgs $eventArgs): void
110 | {
111 | $em = $eventArgs->getObjectManager();
112 | $conn = $em->getConnection();
113 | $platform = $conn->getDatabasePlatform();
114 | $quoteStrategy = $em->getConfiguration()->getQuoteStrategy();
115 | $uow = $em->getUnitOfWork();
116 |
117 | foreach ($this->extraUpdates as $entity) {
118 | $className = $entity::class;
119 | $meta = $em->getClassMetadata($className);
120 |
121 | $persister = $uow->getEntityPersister($className);
122 | $updateData = $this->prepareUpdateData($em, $persister, $entity);
123 |
124 | if (!isset($updateData[$meta->table['name']]) || [] === $updateData[$meta->table['name']]) {
125 | continue;
126 | }
127 |
128 | $sql = 'UPDATE '.$this->config->getTableName($meta);
129 | $params = $types = [];
130 |
131 | foreach ($updateData[$meta->table['name']] as $column => $value) {
132 | /** @phpstan-var literal-string $field */
133 | $field = $meta->getFieldName($column);
134 | $fieldName = $meta->getFieldForColumn($column);
135 | $placeholder = '?';
136 | if ($meta->hasField($fieldName)) {
137 | /** @phpstan-var literal-string $field */
138 | $field = $quoteStrategy->getColumnName($field, $meta, $platform);
139 | $fieldType = $meta->getTypeOfField($fieldName);
140 | if (null !== $fieldType) {
141 | $type = Type::getType($fieldType);
142 | /** @phpstan-var literal-string $placeholder */
143 | $placeholder = $type->convertToDatabaseValueSQL('?', $platform);
144 | }
145 | }
146 |
147 | if ($column === array_key_first($updateData[$meta->table['name']])) {
148 | $sql .= ' SET';
149 | } else {
150 | $sql .= ',';
151 | }
152 |
153 | $sql .= ' '.$field.' = '.$placeholder;
154 |
155 | $params[] = $value;
156 |
157 | if (\array_key_exists($column, $meta->fieldNames)) {
158 | $types[] = $meta->getTypeOfField($fieldName);
159 | } else {
160 | // try to find column in association mappings
161 | $type = null;
162 |
163 | foreach ($meta->associationMappings as $mapping) {
164 | if (isset($mapping['joinColumns'])) {
165 | foreach ($mapping['joinColumns'] as $definition) {
166 | if (self::getMappingNameValue($definition) === $column) {
167 | $targetTable = $em->getClassMetadata(self::getMappingTargetEntityValue($mapping));
168 | $type = $targetTable->getTypeOfField($targetTable->getFieldForColumn(self::getMappingValue($definition, 'referencedColumnName')));
169 | }
170 | }
171 | }
172 | }
173 |
174 | if (null === $type) {
175 | throw new \RuntimeException(\sprintf('Could not resolve database type for column "%s" during extra updates', $column));
176 | }
177 |
178 | $types[] = $type;
179 | }
180 | }
181 |
182 | $sql .= ' WHERE '.$this->config->getRevisionFieldName().' = ?';
183 | $params[] = $this->getRevisionId($conn);
184 | $types[] = $this->config->getRevisionIdFieldType();
185 |
186 | foreach ($meta->identifier as $idField) {
187 | if (isset($meta->fieldMappings[$idField])) {
188 | $columnName = self::getMappingColumnNameValue($meta->fieldMappings[$idField]);
189 | $types[] = self::getMappingValue($meta->fieldMappings[$idField], 'type');
190 | } elseif (isset($meta->associationMappings[$idField]['joinColumns'])) {
191 | $columnName = self::getMappingNameValue($meta->associationMappings[$idField]['joinColumns'][0]);
192 | if ($this->isDbal4()) {
193 | $types[] = ParameterType::STRING;
194 | } else {
195 | $types[] = $meta->associationMappings[$idField]['type'];
196 | }
197 | } else {
198 | throw new \RuntimeException('column name not found for'.$idField);
199 | }
200 |
201 | $params[] = $meta->getFieldValue($entity, $idField);
202 |
203 | $sql .= ' AND '.$columnName.' = ?';
204 | }
205 |
206 | $em->getConnection()->executeQuery($sql, $params, $types);
207 | }
208 |
209 | foreach ($this->deferredChangedManyToManyEntityRevisionsToPersist as $deferredChangedManyToManyEntityRevisionToPersist) {
210 | $this->recordRevisionForManyToManyEntity(
211 | $deferredChangedManyToManyEntityRevisionToPersist->getEntity(),
212 | $em,
213 | $deferredChangedManyToManyEntityRevisionToPersist->getRevType(),
214 | $deferredChangedManyToManyEntityRevisionToPersist->getEntityData(),
215 | $deferredChangedManyToManyEntityRevisionToPersist->getAssoc(),
216 | $deferredChangedManyToManyEntityRevisionToPersist->getClass(),
217 | $deferredChangedManyToManyEntityRevisionToPersist->getTargetClass(),
218 | );
219 | }
220 |
221 | $this->deferredChangedManyToManyEntityRevisionsToPersist = [];
222 | }
223 |
224 | public function postPersist(PostPersistEventArgs $eventArgs): void
225 | {
226 | $em = $eventArgs->getObjectManager();
227 | // onFlush was executed before, everything already initialized
228 | $entity = $eventArgs->getObject();
229 |
230 | $class = $em->getClassMetadata($entity::class);
231 | if (!$this->metadataFactory->isAudited($class->name)) {
232 | return;
233 | }
234 |
235 | $entityData = array_merge(
236 | $this->getOriginalEntityData($em, $entity),
237 | $this->getManyToManyRelations($em, $entity)
238 | );
239 | $this->saveRevisionEntityData($em, $class, $entityData, 'INS');
240 | }
241 |
242 | public function postUpdate(PostUpdateEventArgs $eventArgs): void
243 | {
244 | $em = $eventArgs->getObjectManager();
245 | $uow = $em->getUnitOfWork();
246 |
247 | // onFlush was executed before, everything already initialized
248 | $entity = $eventArgs->getObject();
249 |
250 | $class = $em->getClassMetadata($entity::class);
251 | if (!$this->metadataFactory->isAudited($class->name)) {
252 | return;
253 | }
254 |
255 | // get changes => should be already computed here (is a listener)
256 | $changeset = $uow->getEntityChangeSet($entity);
257 | foreach ($this->config->getGlobalIgnoreColumns() as $column) {
258 | if (isset($changeset[$column])) {
259 | unset($changeset[$column]);
260 | }
261 | }
262 |
263 | // if we have no changes left => don't create revision log
264 | if (0 === \count($changeset)) {
265 | return;
266 | }
267 |
268 | $entityData = array_merge(
269 | $this->getOriginalEntityData($em, $entity),
270 | $uow->getEntityIdentifier($entity),
271 | $this->getManyToManyRelations($em, $entity)
272 | );
273 |
274 | $this->saveRevisionEntityData($em, $class, $entityData, 'UPD');
275 | }
276 |
277 | public function onClear(): void
278 | {
279 | $this->extraUpdates = [];
280 | }
281 |
282 | public function onFlush(OnFlushEventArgs $eventArgs): void
283 | {
284 | $em = $eventArgs->getObjectManager();
285 | $uow = $em->getUnitOfWork();
286 | $this->revisionId = null; // reset revision
287 |
288 | $processedEntities = [];
289 |
290 | foreach ($uow->getScheduledEntityDeletions() as $entity) {
291 | // doctrine is fine deleting elements multiple times. We are not.
292 | $hash = $this->getHash($uow, $entity);
293 |
294 | if (\in_array($hash, $processedEntities, true)) {
295 | continue;
296 | }
297 |
298 | $processedEntities[] = $hash;
299 |
300 | $class = $em->getClassMetadata($entity::class);
301 | if (!$this->metadataFactory->isAudited($class->name)) {
302 | continue;
303 | }
304 |
305 | $entityData = array_merge(
306 | $this->getOriginalEntityData($em, $entity),
307 | $uow->getEntityIdentifier($entity),
308 | $this->getManyToManyRelations($em, $entity)
309 | );
310 | $this->saveRevisionEntityData($em, $class, $entityData, 'DEL');
311 | }
312 |
313 | foreach ($uow->getScheduledEntityInsertions() as $entity) {
314 | if (!$this->metadataFactory->isAudited($entity::class)) {
315 | continue;
316 | }
317 |
318 | $this->extraUpdates[spl_object_hash($entity)] = $entity;
319 | }
320 |
321 | foreach ($uow->getScheduledEntityUpdates() as $entity) {
322 | if (!$this->metadataFactory->isAudited($entity::class)) {
323 | continue;
324 | }
325 |
326 | $this->extraUpdates[spl_object_hash($entity)] = $entity;
327 | }
328 | }
329 |
330 | /**
331 | * Get original entity data, including versioned field, if "version" constraint is used.
332 | *
333 | * @return array
334 | */
335 | private function getOriginalEntityData(EntityManagerInterface $em, object $entity): array
336 | {
337 | $class = $em->getClassMetadata($entity::class);
338 | $data = $em->getUnitOfWork()->getOriginalEntityData($entity);
339 | if ($class->isVersioned) {
340 | $versionField = $class->versionField;
341 | \assert(null !== $versionField);
342 | $data[$versionField] = $class->getFieldValue($entity, $versionField);
343 | }
344 |
345 | return $data;
346 | }
347 |
348 | /**
349 | * Get many to many relations data.
350 | *
351 | * @return array
352 | */
353 | private function getManyToManyRelations(EntityManagerInterface $em, object $entity): array
354 | {
355 | $data = [];
356 | $class = $em->getClassMetadata($entity::class);
357 | foreach ($class->associationMappings as $field => $assoc) {
358 | if (self::isManyToManyOwningSideMapping($assoc)) {
359 | $data[$field] = $class->getFieldValue($entity, $field);
360 | }
361 | }
362 |
363 | return $data;
364 | }
365 |
366 | /**
367 | * @return string|int
368 | */
369 | private function getRevisionId(Connection $conn)
370 | {
371 | $now = $this->clock instanceof ClockInterface ? $this->clock->now() : new \DateTimeImmutable();
372 |
373 | if (null === $this->revisionId) {
374 | $conn->insert(
375 | $this->config->getRevisionTableName(),
376 | [
377 | 'timestamp' => $now,
378 | 'username' => $this->config->getCurrentUsername(),
379 | ],
380 | [
381 | Types::DATETIME_IMMUTABLE,
382 | Types::STRING,
383 | ]
384 | );
385 |
386 | $revisionId = $conn->lastInsertId();
387 | /*
388 | * NEXT_MAJOR: Remove this `if` block, because lastInsertId throws an exception in DBAL 4
389 | */
390 | if (false === $revisionId) { // @phpstan-ignore-line doctrine/dbal 3 lastInsertId() can return false
391 | throw new \RuntimeException('Unable to retrieve the last revision id.');
392 | }
393 |
394 | $this->revisionId = $revisionId;
395 | }
396 |
397 | return $this->revisionId;
398 | }
399 |
400 | /**
401 | * @param ClassMetadata $class
402 | *
403 | * @throws Exception
404 | *
405 | * @return literal-string
406 | */
407 | private function getInsertRevisionSQL(EntityManagerInterface $em, ClassMetadata $class): string
408 | {
409 | if (!isset($this->insertRevisionSQL[$class->name])) {
410 | $placeholders = ['?', '?'];
411 | $tableName = $this->config->getTableName($class);
412 |
413 | $sql = 'INSERT INTO '.$tableName.' ('.
414 | $this->config->getRevisionFieldName().', '.$this->config->getRevisionTypeFieldName();
415 |
416 | $fields = [];
417 |
418 | foreach ($class->associationMappings as $field => $assoc) {
419 | if ($class->isInheritanceTypeJoined() && $class->isInheritedAssociation($field)) {
420 | continue;
421 | }
422 |
423 | if (self::isToOneOwningSide($assoc)) {
424 | foreach (self::getTargetToSourceKeyColumns($assoc) as $sourceCol) {
425 | $fields[$sourceCol] = true;
426 | $sql .= ', '.$sourceCol;
427 | $placeholders[] = '?';
428 | }
429 | }
430 | }
431 |
432 | foreach ($class->fieldNames as $field) {
433 | if (\array_key_exists($field, $fields)) {
434 | continue;
435 | }
436 |
437 | if ($class->isInheritanceTypeJoined()
438 | && $class->isInheritedField($field)
439 | && !$class->isIdentifier($field)
440 | ) {
441 | continue;
442 | }
443 |
444 | $platform = $em->getConnection()->getDatabasePlatform();
445 | $type = Type::getType(self::getMappingValue($class->fieldMappings[$field], 'type'));
446 |
447 | /** @phpstan-var literal-string $placeholder */
448 | $placeholder = $type->convertToDatabaseValueSQL('?', $platform);
449 | $placeholders[] = $placeholder;
450 |
451 | /** @phpstan-var literal-string $columnName */
452 | $columnName = $em->getConfiguration()->getQuoteStrategy()->getColumnName($field, $class, $platform);
453 | $sql .= ', '.$columnName;
454 | }
455 |
456 | if (
457 | (
458 | ($class->isInheritanceTypeJoined() && $class->rootEntityName === $class->name)
459 | || $class->isInheritanceTypeSingleTable()
460 | )
461 | && null !== $class->discriminatorColumn
462 | ) {
463 | $discriminatorColumnName = self::getMappingNameValue($class->discriminatorColumn);
464 | $sql .= ', '.$discriminatorColumnName;
465 | $placeholders[] = '?';
466 | }
467 |
468 | $sql .= ') VALUES ('.implode(', ', $placeholders).')';
469 |
470 | $this->insertRevisionSQL[$class->name] = $sql;
471 | }
472 |
473 | return $this->insertRevisionSQL[$class->name];
474 | }
475 |
476 | /**
477 | * @param ClassMetadata $class
478 | * @param ClassMetadata $targetClass
479 | * @param array|ManyToManyOwningSideMapping $assoc
480 | *
481 | * @return literal-string
482 | */
483 | private function getInsertJoinTableRevisionSQL(
484 | ClassMetadata $class,
485 | ClassMetadata $targetClass,
486 | array|ManyToManyOwningSideMapping $assoc,
487 | ): string {
488 | $joinTableName = self::getMappingJoinTableNameValue($assoc);
489 | $cacheKey = $class->name.'.'.$targetClass->name.'.'.$joinTableName;
490 |
491 | if (
492 | !isset($this->insertJoinTableRevisionSQL[$cacheKey])
493 | ) {
494 | $placeholders = ['?', '?'];
495 |
496 | $tableName = $this->config->getTablePrefix().$joinTableName.$this->config->getTableSuffix();
497 |
498 | $sql = 'INSERT INTO '.$tableName
499 | .' ('.$this->config->getRevisionFieldName().
500 | ', '.$this->config->getRevisionTypeFieldName();
501 |
502 | foreach (self::getRelationToSourceKeyColumns($assoc) as $sourceColumn => $targetColumn) {
503 | $sql .= ', '.$sourceColumn;
504 | $placeholders[] = '?';
505 | }
506 |
507 | foreach (self::getRelationToTargetKeyColumns($assoc) as $sourceColumn => $targetColumn) {
508 | $sql .= ', '.$sourceColumn;
509 | $placeholders[] = '?';
510 | }
511 |
512 | $sql .= ') VALUES ('.implode(', ', $placeholders).')';
513 |
514 | $this->insertJoinTableRevisionSQL[$cacheKey] = $sql;
515 | }
516 |
517 | return $this->insertJoinTableRevisionSQL[$cacheKey];
518 | }
519 |
520 | /**
521 | * @param ClassMetadata $class
522 | * @param array $entityData
523 | */
524 | private function saveRevisionEntityData(EntityManagerInterface $em, ClassMetadata $class, array $entityData, string $revType): void
525 | {
526 | $uow = $em->getUnitOfWork();
527 | $conn = $em->getConnection();
528 |
529 | $params = [$this->getRevisionId($conn), $revType];
530 | if ($this->isDbal4()) {
531 | $types = [ParameterType::INTEGER, ParameterType::STRING];
532 | } else {
533 | $types = [\PDO::PARAM_INT, \PDO::PARAM_STR];
534 | }
535 |
536 | $fields = [];
537 |
538 | foreach ($class->associationMappings as $field => $assoc) {
539 | if ($class->isInheritanceTypeJoined() && $class->isInheritedAssociation($field)) {
540 | continue;
541 | }
542 |
543 | if (self::isOwningSide($assoc)) {
544 | if (self::isToOneOwningSide($assoc)) {
545 | $data = $entityData[$field] ?? null;
546 | $relatedId = [];
547 |
548 | if (\is_object($data) && $uow->isInIdentityMap($data)) {
549 | $relatedId = $uow->getEntityIdentifier($data);
550 | }
551 |
552 | $targetClass = $em->getClassMetadata(self::getMappingTargetEntityValue($assoc));
553 |
554 | foreach (self::getSourceToTargetKeyColumns($assoc) as $sourceColumn => $targetColumn) {
555 | $fields[$sourceColumn] = true;
556 | if (null === $data) {
557 | $params[] = null;
558 | if ($this->isDbal4()) {
559 | $types[] = ParameterType::STRING;
560 | } else {
561 | $types[] = \PDO::PARAM_STR;
562 | }
563 | } else {
564 | $params[] = $relatedId[$targetClass->fieldNames[$targetColumn]] ?? null;
565 | $types[] = $targetClass->getTypeOfField($targetClass->getFieldForColumn($targetColumn));
566 | }
567 | }
568 | } elseif (self::isManyToManyOwningSideMapping($assoc)) {
569 | $targetClass = $em->getClassMetadata(self::getMappingTargetEntityValue($assoc));
570 |
571 | $collection = $entityData[$assoc['fieldName']];
572 | if (null !== $collection) {
573 | foreach ($collection as $relatedEntity) {
574 | if (null === $uow->getSingleIdentifierValue($relatedEntity)) {
575 | // due to the commit order of the UoW the $relatedEntity hasn't yet been flushed to the DB so it doesn't have an ID assigned yet
576 | // so we have to defer writing the revision record to the DB to the postFlush event by which point we know that the entity is gonna be flushed and have the ID assigned
577 | $this->deferredChangedManyToManyEntityRevisionsToPersist[] = new DeferredChangedManyToManyEntityRevisionToPersist($relatedEntity, $revType, $entityData, $assoc, $class, $targetClass);
578 | } else {
579 | $this->recordRevisionForManyToManyEntity($relatedEntity, $em, $revType, $entityData, $assoc, $class, $targetClass);
580 | }
581 | }
582 | }
583 | }
584 | }
585 | }
586 |
587 | foreach ($class->fieldNames as $field) {
588 | if (\array_key_exists($field, $fields)) {
589 | continue;
590 | }
591 |
592 | if ($class->isInheritanceTypeJoined()
593 | && $class->isInheritedField($field)
594 | && !$class->isIdentifier($field)
595 | ) {
596 | continue;
597 | }
598 |
599 | $params[] = $entityData[$field] ?? null;
600 | $types[] = self::getMappingValue($class->fieldMappings[$field], 'type');
601 | }
602 |
603 | if (
604 | $class->isInheritanceTypeSingleTable()
605 | && null !== $class->discriminatorColumn
606 | ) {
607 | $params[] = $class->discriminatorValue;
608 | $types[] = self::getMappingValue($class->discriminatorColumn, 'type');
609 | } elseif (
610 | $class->isInheritanceTypeJoined()
611 | && $class->name === $class->rootEntityName
612 | && null !== $class->discriminatorColumn
613 | ) {
614 | $params[] = $entityData[self::getMappingNameValue($class->discriminatorColumn)];
615 | $types[] = self::getMappingValue($class->discriminatorColumn, 'type');
616 | }
617 |
618 | if (
619 | $class->isInheritanceTypeJoined() && $class->name !== $class->rootEntityName
620 | && null !== $class->discriminatorColumn
621 | ) {
622 | $entityData[self::getMappingNameValue($class->discriminatorColumn)] = $class->discriminatorValue;
623 | $this->saveRevisionEntityData(
624 | $em,
625 | $em->getClassMetadata($class->rootEntityName),
626 | $entityData,
627 | $revType
628 | );
629 | }
630 |
631 | foreach ($params as $key => $parameterValue) {
632 | if ($parameterValue instanceof \BackedEnum) {
633 | $params[$key] = $parameterValue->value;
634 | }
635 | }
636 |
637 | $conn->executeStatement($this->getInsertRevisionSQL($em, $class), $params, $types);
638 | }
639 |
640 | /**
641 | * @param array|ManyToManyOwningSideMapping $assoc
642 | * @param array $entityData
643 | * @param ClassMetadata $class
644 | * @param ClassMetadata $targetClass
645 | */
646 | private function recordRevisionForManyToManyEntity(
647 | object $relatedEntity,
648 | EntityManagerInterface $em,
649 | string $revType,
650 | array $entityData,
651 | array|ManyToManyOwningSideMapping $assoc,
652 | ClassMetadata $class,
653 | ClassMetadata $targetClass,
654 | ): void {
655 | $conn = $em->getConnection();
656 | $joinTableParams = [$this->getRevisionId($conn), $revType];
657 | if ($this->isDbal4()) {
658 | $joinTableTypes = [ParameterType::INTEGER, ParameterType::STRING];
659 | } else {
660 | $joinTableTypes = [\PDO::PARAM_INT, \PDO::PARAM_STR];
661 | }
662 |
663 | foreach (self::getRelationToSourceKeyColumns($assoc) as $targetColumn) {
664 | $joinTableParams[] = $entityData[$class->fieldNames[$targetColumn]];
665 | $joinTableTypes[] = PersisterHelper::getTypeOfColumn($targetColumn, $class, $em);
666 | }
667 |
668 | foreach (self::getRelationToTargetKeyColumns($assoc) as $targetColumn) {
669 | $joinTableParams[] = $targetClass->getFieldValue($relatedEntity, $targetClass->fieldNames[$targetColumn]);
670 | $joinTableTypes[] = PersisterHelper::getTypeOfColumn($targetColumn, $targetClass, $em);
671 | }
672 | $conn->executeStatement(
673 | $this->getInsertJoinTableRevisionSQL($class, $targetClass, $assoc),
674 | $joinTableParams,
675 | $joinTableTypes // @phpstan-ignore-line for doctrine/dbal 3 type can be integer
676 | );
677 | }
678 |
679 | private function getHash(UnitOfWork $uow, object $entity): string
680 | {
681 | return implode(
682 | ' ',
683 | array_merge(
684 | [$entity::class],
685 | $uow->getEntityIdentifier($entity)
686 | )
687 | );
688 | }
689 |
690 | /**
691 | * Modified version of \Doctrine\ORM\Persisters\Entity\BasicEntityPersister::prepareUpdateData()
692 | * git revision d9fc5388f1aa1751a0e148e76b4569bd207338e9 (v2.5.3).
693 | *
694 | * @license MIT
695 | *
696 | * @author Roman Borschel
697 | * @author Giorgio Sironi
698 | * @author Benjamin Eberlei
699 | * @author Alexander
700 | * @author Fabio B. Silva
701 | * @author Rob Caiger
702 | * @author Simon Mönch
703 | *
704 | * @return array>
705 | */
706 | private function prepareUpdateData(EntityManagerInterface $em, EntityPersister $persister, object $entity): array
707 | {
708 | $uow = $em->getUnitOfWork();
709 | $classMetadata = $persister->getClassMetadata();
710 |
711 | $versionField = null;
712 | $result = [];
713 |
714 | if (false !== $classMetadata->isVersioned) {
715 | $versionField = $classMetadata->versionField;
716 | }
717 |
718 | foreach ($uow->getEntityChangeSet($entity) as $field => $change) {
719 | if (isset($versionField) && $versionField === $field) {
720 | continue;
721 | }
722 |
723 | if (isset($classMetadata->embeddedClasses[$field])) {
724 | continue;
725 | }
726 |
727 | $newVal = $change[1];
728 |
729 | if (!isset($classMetadata->associationMappings[$field])) {
730 | $columnName = self::getMappingColumnNameValue($classMetadata->fieldMappings[$field]);
731 | $result[$persister->getOwningTable($field)][$columnName] = $newVal;
732 |
733 | continue;
734 | }
735 |
736 | $assoc = $classMetadata->associationMappings[$field];
737 |
738 | // Only owning side of x-1 associations can have a FK column.
739 | if (!self::isToOneOwningSide($assoc)) {
740 | continue;
741 | }
742 |
743 | if (null !== $newVal) {
744 | if ($uow->isScheduledForInsert($newVal)) {
745 | $newVal = null;
746 | }
747 | }
748 |
749 | $newValId = null;
750 |
751 | if (\is_object($newVal)) {
752 | if (!$uow->isInIdentityMap($newVal)) {
753 | continue;
754 | }
755 |
756 | $newValId = $uow->getEntityIdentifier($newVal);
757 | }
758 |
759 | $targetClass = $em->getClassMetadata(self::getMappingTargetEntityValue($assoc));
760 | $owningTable = $persister->getOwningTable($field);
761 |
762 | foreach ($assoc['joinColumns'] as $joinColumn) {
763 | $sourceColumn = self::getMappingNameValue($joinColumn);
764 | $targetColumn = self::getMappingValue($joinColumn, 'referencedColumnName');
765 |
766 | $result[$owningTable][$sourceColumn] = null !== $newValId
767 | ? $newValId[$targetClass->getFieldForColumn($targetColumn)]
768 | : null;
769 | }
770 | }
771 |
772 | return $result;
773 | }
774 | }
775 |
--------------------------------------------------------------------------------
/src/AuditReader.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace SimpleThings\EntityAudit;
15 |
16 | use Doctrine\Common\Collections\ArrayCollection;
17 | use Doctrine\DBAL\Connection;
18 | use Doctrine\DBAL\Exception;
19 | use Doctrine\DBAL\Platforms\AbstractPlatform;
20 | use Doctrine\DBAL\Types\Type;
21 | use Doctrine\ORM\EntityManagerInterface;
22 | use Doctrine\ORM\Exception\ORMException;
23 | use Doctrine\ORM\Mapping\ClassMetadata;
24 | use Doctrine\ORM\Mapping\QuoteStrategy;
25 | use Doctrine\ORM\PersistentCollection;
26 | use Doctrine\ORM\Persisters\Entity\EntityPersister;
27 | use SimpleThings\EntityAudit\Collection\AuditedCollection;
28 | use SimpleThings\EntityAudit\Exception\DeletedException;
29 | use SimpleThings\EntityAudit\Exception\InvalidRevisionException;
30 | use SimpleThings\EntityAudit\Exception\NoRevisionFoundException;
31 | use SimpleThings\EntityAudit\Exception\NotAuditedException;
32 | use SimpleThings\EntityAudit\Metadata\MetadataFactory;
33 | use SimpleThings\EntityAudit\Utils\ArrayDiff;
34 | use SimpleThings\EntityAudit\Utils\ORMCompatibilityTrait;
35 | use SimpleThings\EntityAudit\Utils\SQLResultCasing;
36 |
37 | /**
38 | * NEXT_MAJOR: Declare the class as final.
39 | *
40 | * @final since 1.19.0
41 | */
42 | class AuditReader
43 | {
44 | use ORMCompatibilityTrait;
45 | use SQLResultCasing;
46 |
47 | private AbstractPlatform $platform;
48 |
49 | private QuoteStrategy $quoteStrategy;
50 |
51 | /**
52 | * Entity cache to prevent circular references.
53 | *
54 | * @var array>>
55 | *
56 | * @phpstan-var array>>
57 | */
58 | private array $entityCache = [];
59 |
60 | /**
61 | * Decides if audited ToMany collections are loaded.
62 | */
63 | private bool $loadAuditedCollections = true;
64 |
65 | /**
66 | * Decides if audited ToOne collections are loaded.
67 | */
68 | private bool $loadAuditedEntities = true;
69 |
70 | /**
71 | * Decides if native (not audited) ToMany collections are loaded.
72 | */
73 | private bool $loadNativeCollections = true;
74 |
75 | /**
76 | * Decides if native (not audited) ToOne collections are loaded.
77 | */
78 | private bool $loadNativeEntities = true;
79 |
80 | public function __construct(
81 | private EntityManagerInterface $em,
82 | private AuditConfiguration $config,
83 | private MetadataFactory $metadataFactory,
84 | ) {
85 | $this->platform = $this->em->getConnection()->getDatabasePlatform();
86 | $this->quoteStrategy = $this->em->getConfiguration()->getQuoteStrategy();
87 | }
88 |
89 | /**
90 | * @return bool
91 | */
92 | public function isLoadAuditedCollections()
93 | {
94 | return $this->loadAuditedCollections;
95 | }
96 |
97 | /**
98 | * @param bool $loadAuditedCollections
99 | */
100 | public function setLoadAuditedCollections($loadAuditedCollections): void
101 | {
102 | $this->loadAuditedCollections = $loadAuditedCollections;
103 | }
104 |
105 | /**
106 | * @return bool
107 | */
108 | public function isLoadAuditedEntities()
109 | {
110 | return $this->loadAuditedEntities;
111 | }
112 |
113 | /**
114 | * @param bool $loadAuditedEntities
115 | */
116 | public function setLoadAuditedEntities($loadAuditedEntities): void
117 | {
118 | $this->loadAuditedEntities = $loadAuditedEntities;
119 | }
120 |
121 | /**
122 | * @return bool
123 | */
124 | public function isLoadNativeCollections()
125 | {
126 | return $this->loadNativeCollections;
127 | }
128 |
129 | /**
130 | * @param bool $loadNativeCollections
131 | */
132 | public function setLoadNativeCollections($loadNativeCollections): void
133 | {
134 | $this->loadNativeCollections = $loadNativeCollections;
135 | }
136 |
137 | /**
138 | * @return bool
139 | */
140 | public function isLoadNativeEntities()
141 | {
142 | return $this->loadNativeEntities;
143 | }
144 |
145 | /**
146 | * @param bool $loadNativeEntities
147 | */
148 | public function setLoadNativeEntities($loadNativeEntities): void
149 | {
150 | $this->loadNativeEntities = $loadNativeEntities;
151 | }
152 |
153 | /**
154 | * @return Connection
155 | */
156 | public function getConnection()
157 | {
158 | return $this->em->getConnection();
159 | }
160 |
161 | /**
162 | * @return AuditConfiguration
163 | */
164 | public function getConfiguration()
165 | {
166 | return $this->config;
167 | }
168 |
169 | /**
170 | * Clears entity cache. Call this if you are fetching subsequent revisions using same AuditManager.
171 | */
172 | public function clearEntityCache(): void
173 | {
174 | $this->entityCache = [];
175 | }
176 |
177 | /**
178 | * Find a class at the specific revision.
179 | *
180 | * This method does not require the revision to be exact but it also searches for an earlier revision
181 | * of this entity and always returns the latest revision below or equal the given revision. Commonly, it
182 | * returns last revision INCLUDING "DEL" revision. If you want to throw exception instead, set
183 | * $threatDeletionAsException to true.
184 | *
185 | * @template T of object
186 | *
187 | * @param string $className
188 | * @param int|string|array $id
189 | * @param int|string $revision
190 | * @param array{threatDeletionsAsExceptions?: bool} $options
191 | *
192 | * @throws DeletedException
193 | * @throws NoRevisionFoundException
194 | * @throws NotAuditedException
195 | * @throws Exception
196 | * @throws ORMException
197 | * @throws \RuntimeException
198 | *
199 | * @return object
200 | *
201 | * @phpstan-param class-string $className
202 | * @phpstan-return T
203 | */
204 | public function find($className, $id, $revision, array $options = [])
205 | {
206 | $options = array_merge(['threatDeletionsAsExceptions' => false], $options);
207 |
208 | if (!$this->metadataFactory->isAudited($className)) {
209 | throw new NotAuditedException($className);
210 | }
211 |
212 | /** @var ClassMetadata $classMetadata */
213 | $classMetadata = $this->em->getClassMetadata($className);
214 | $tableName = $this->config->getTableName($classMetadata);
215 |
216 | $whereSQL = 'e.'.$this->config->getRevisionFieldName().' <= ?';
217 |
218 | foreach ($classMetadata->identifier as $idField) {
219 | if (\is_array($id) && \count($id) > 0) {
220 | $idKeys = array_keys($id);
221 | $columnName = $idKeys[0];
222 | } elseif (isset($classMetadata->fieldMappings[$idField])) {
223 | $columnName = self::getMappingColumnNameValue($classMetadata->fieldMappings[$idField]);
224 | } elseif (isset($classMetadata->associationMappings[$idField]['joinColumns'])) {
225 | $columnName = $classMetadata->associationMappings[$idField]['joinColumns'][0]['name'];
226 | } else {
227 | throw new \RuntimeException('column name not found for'.$idField);
228 | }
229 |
230 | $whereSQL .= ' AND e.'.$columnName.' = ?';
231 | }
232 |
233 | if (!\is_array($id)) {
234 | $id = [$classMetadata->identifier[0] => $id];
235 | }
236 |
237 | $columnList = ['e.'.$this->config->getRevisionTypeFieldName()];
238 | $columnMap = [];
239 |
240 | foreach ($classMetadata->fieldNames as $columnName => $field) {
241 | $tableAlias = $classMetadata->isInheritanceTypeJoined()
242 | && $classMetadata->isInheritedField($field)
243 | && !$classMetadata->isIdentifier($field)
244 | ? 're' // root entity
245 | : 'e';
246 |
247 | $type = Type::getType(self::getMappingValue($classMetadata->fieldMappings[$field], 'type'));
248 | $columnList[] = \sprintf(
249 | '%s AS %s',
250 | $type->convertToPHPValueSQL(
251 | $tableAlias.'.'.$this->quoteStrategy->getColumnName($field, $classMetadata, $this->platform),
252 | $this->platform
253 | ),
254 | $this->platform->quoteSingleIdentifier($field)
255 | );
256 | $columnMap[$field] = $this->getSQLResultCasing($this->platform, $columnName);
257 | }
258 |
259 | foreach ($classMetadata->associationMappings as $assoc) {
260 | if (!self::isToOneOwningSide($assoc)) {
261 | continue;
262 | }
263 |
264 | /** @var string $sourceCol */
265 | foreach (self::getMappingValue($assoc, 'joinColumnFieldNames') as $sourceCol) {
266 | $tableAlias = $classMetadata->isInheritanceTypeJoined()
267 | && $classMetadata->isInheritedAssociation(self::getMappingFieldNameValue($assoc))
268 | && !$classMetadata->isIdentifier(self::getMappingFieldNameValue($assoc))
269 | ? 're' // root entity
270 | : 'e';
271 | $columnList[] = $tableAlias.'.'.$sourceCol;
272 | $columnMap[$sourceCol] = $this->getSQLResultCasing($this->platform, $sourceCol);
273 | }
274 | }
275 |
276 | $joinSql = '';
277 | if ($classMetadata->isInheritanceTypeJoined() && $classMetadata->name !== $classMetadata->rootEntityName) {
278 | $rootClass = $this->em->getClassMetadata($classMetadata->rootEntityName);
279 | $rootTableName = $this->config->getTableName($rootClass);
280 | $joinSql = "INNER JOIN {$rootTableName} re ON";
281 | $joinSql .= ' re.'.$this->config->getRevisionFieldName().' = e.'.$this->config->getRevisionFieldName();
282 | foreach ($classMetadata->getIdentifierColumnNames() as $name) {
283 | $joinSql .= " AND re.$name = e.$name";
284 | }
285 | }
286 |
287 | $values = [...[$revision], ...array_values($id)];
288 |
289 | if (
290 | !$classMetadata->isInheritanceTypeNone()
291 | && null !== $classMetadata->discriminatorColumn
292 | ) {
293 | $columnList[] = self::getMappingNameValue($classMetadata->discriminatorColumn);
294 | if ($classMetadata->isInheritanceTypeSingleTable()
295 | && null !== $classMetadata->discriminatorValue) {
296 | // Support for single table inheritance sub-classes
297 | $allDiscrValues = array_flip($classMetadata->discriminatorMap);
298 | $queriedDiscrValues = [$this->em->getConnection()->quote($classMetadata->discriminatorValue)];
299 | foreach ($classMetadata->subClasses as $subclassName) {
300 | $queriedDiscrValues[] = $this->em->getConnection()->quote((string) $allDiscrValues[$subclassName]);
301 | }
302 |
303 | $whereSQL .= \sprintf(
304 | ' AND %s IN (%s)',
305 | self::getMappingNameValue($classMetadata->discriminatorColumn),
306 | implode(', ', $queriedDiscrValues)
307 | );
308 | }
309 | }
310 |
311 | $query = \sprintf(
312 | 'SELECT %s FROM %s e %s WHERE %s ORDER BY e.%s DESC',
313 | implode(', ', $columnList),
314 | $tableName,
315 | $joinSql,
316 | $whereSQL,
317 | $this->config->getRevisionFieldName()
318 | );
319 |
320 | $row = $this->em->getConnection()->fetchAssociative($query, $values);
321 |
322 | if (false === $row) {
323 | throw new NoRevisionFoundException($classMetadata->name, $id, $revision);
324 | }
325 |
326 | if ($options['threatDeletionsAsExceptions'] && 'DEL' === $row[$this->config->getRevisionTypeFieldName()]) {
327 | throw new DeletedException($classMetadata->name, $id, $revision);
328 | }
329 |
330 | unset($row[$this->config->getRevisionTypeFieldName()]);
331 |
332 | return $this->createEntity($classMetadata->name, $columnMap, $row, $revision);
333 | }
334 |
335 | /**
336 | * NEXT_MAJOR: Change the default value to `null`.
337 | *
338 | * Return a list of all revisions.
339 | *
340 | * @param int|null $limit
341 | * @param int $offset
342 | *
343 | * @throws Exception
344 | *
345 | * @return Revision[]
346 | */
347 | public function findRevisionHistory($limit = 20, $offset = 0)
348 | {
349 | $query = $this->platform->modifyLimitQuery(
350 | 'SELECT * FROM '.$this->config->getRevisionTableName().' ORDER BY id DESC',
351 | $limit,
352 | $offset
353 | );
354 | $revisionsData = $this->em->getConnection()->fetchAllAssociative($query);
355 |
356 | $revisions = [];
357 | foreach ($revisionsData as $row) {
358 | $timestamp = \DateTime::createFromFormat($this->platform->getDateTimeFormatString(), $row['timestamp']);
359 | \assert(false !== $timestamp);
360 |
361 | $revisions[] = new Revision($row['id'], $timestamp, $row['username']);
362 | }
363 |
364 | return $revisions;
365 | }
366 |
367 | /**
368 | * NEXT_MAJOR: Remove this method.
369 | *
370 | * @param int|string $revision
371 | *
372 | * @return ChangedEntity[]
373 | *
374 | * @deprecated this function name is misspelled.
375 | * Suggest using findEntitiesChangedAtRevision instead.
376 | */
377 | public function findEntitesChangedAtRevision($revision)
378 | {
379 | return $this->findEntitiesChangedAtRevision($revision);
380 | }
381 |
382 | /**
383 | * Return a list of ChangedEntity instances created at the given revision.
384 | *
385 | * @param int|string $revision
386 | *
387 | * @throws NoRevisionFoundException
388 | * @throws NotAuditedException
389 | * @throws Exception
390 | * @throws ORMException
391 | * @throws \RuntimeException
392 | * @throws DeletedException
393 | *
394 | * @return ChangedEntity[]
395 | */
396 | public function findEntitiesChangedAtRevision($revision)
397 | {
398 | $auditedEntities = $this->metadataFactory->getAllClassNames();
399 |
400 | $changedEntities = [];
401 | foreach ($auditedEntities as $className) {
402 | $classMetadata = $this->em->getClassMetadata($className);
403 |
404 | if ($classMetadata->isInheritanceTypeSingleTable() && \count($classMetadata->subClasses) > 0) {
405 | continue;
406 | }
407 |
408 | $tableName = $this->config->getTableName($classMetadata);
409 | $params = [];
410 |
411 | $whereSQL = 'e.'.$this->config->getRevisionFieldName().' = ?';
412 | $columnList = 'e.'.$this->config->getRevisionTypeFieldName();
413 | $params[] = $revision;
414 | $columnMap = [];
415 |
416 | foreach ($classMetadata->fieldNames as $columnName => $field) {
417 | $type = Type::getType(self::getMappingValue($classMetadata->fieldMappings[$field], 'type'));
418 | $tableAlias = $classMetadata->isInheritanceTypeJoined()
419 | && $classMetadata->isInheritedField($field)
420 | && !$classMetadata->isIdentifier($field)
421 | ? 're' // root entity
422 | : 'e';
423 | $columnList .= ', '.$type->convertToPHPValueSQL(
424 | $tableAlias.'.'.$this->quoteStrategy->getColumnName($field, $classMetadata, $this->platform),
425 | $this->platform
426 | ).' AS '.$this->platform->quoteSingleIdentifier($field);
427 | $columnMap[$field] = $this->getSQLResultCasing($this->platform, $columnName);
428 | }
429 |
430 | foreach ($classMetadata->associationMappings as $assoc) {
431 | if (self::isToOneOwningSide($assoc)) {
432 | foreach (self::getTargetToSourceKeyColumns($assoc) as $sourceCol) {
433 | $columnList .= ', '.$sourceCol;
434 | $columnMap[$sourceCol] = $this->getSQLResultCasing($this->platform, $sourceCol);
435 | }
436 | }
437 | }
438 |
439 | $joinSql = '';
440 | if (
441 | $classMetadata->isInheritanceTypeSingleTable()
442 | && null !== $classMetadata->discriminatorColumn
443 | ) {
444 | $columnList .= ', e.'.self::getMappingNameValue($classMetadata->discriminatorColumn);
445 | $whereSQL .= ' AND e.'.self::getMappingFieldNameValue($classMetadata->discriminatorColumn).' = ?';
446 | $params[] = $classMetadata->discriminatorValue;
447 | } elseif (
448 | $classMetadata->isInheritanceTypeJoined()
449 | && $classMetadata->rootEntityName !== $classMetadata->name
450 | && null !== $classMetadata->discriminatorColumn
451 | ) {
452 | $columnList .= ', re.'.self::getMappingNameValue($classMetadata->discriminatorColumn);
453 |
454 | $rootClass = $this->em->getClassMetadata($classMetadata->rootEntityName);
455 | $rootTableName = $this->config->getTableName($rootClass);
456 |
457 | $joinSql = "INNER JOIN {$rootTableName} re ON";
458 | $joinSql .= ' re.'.$this->config->getRevisionFieldName().' = e.'.$this->config->getRevisionFieldName();
459 | foreach ($classMetadata->getIdentifierColumnNames() as $name) {
460 | $joinSql .= " AND re.$name = e.$name";
461 | }
462 | }
463 |
464 | $query = 'SELECT '.$columnList.' FROM '.$tableName.' e '.$joinSql.' WHERE '.$whereSQL;
465 | $revisionsData = $this->em->getConnection()->fetchAllAssociative($query, $params);
466 |
467 | foreach ($revisionsData as $row) {
468 | $id = [];
469 |
470 | foreach ($classMetadata->identifier as $idField) {
471 | $id[$idField] = $row[$idField];
472 | }
473 |
474 | $entity = $this->createEntity($className, $columnMap, $row, $revision);
475 | $changedEntities[] = new ChangedEntity(
476 | $className,
477 | $id,
478 | $row[$this->config->getRevisionTypeFieldName()],
479 | $entity
480 | );
481 | }
482 | }
483 |
484 | return $changedEntities;
485 | }
486 |
487 | /**
488 | * Return the revision object for a particular revision.
489 | *
490 | * @param int|string $revision
491 | *
492 | * @throws Exception
493 | * @throws InvalidRevisionException
494 | *
495 | * @return Revision
496 | */
497 | public function findRevision($revision)
498 | {
499 | $query = 'SELECT * FROM '.$this->config->getRevisionTableName().' r WHERE r.id = ?';
500 | $revisionsData = $this->em->getConnection()->fetchAllAssociative($query, [$revision]);
501 |
502 | if (1 === \count($revisionsData)) {
503 | $timestamp = \DateTime::createFromFormat(
504 | $this->platform->getDateTimeFormatString(),
505 | $revisionsData[0]['timestamp']
506 | );
507 | \assert(false !== $timestamp);
508 |
509 | return new Revision($revisionsData[0]['id'], $timestamp, $revisionsData[0]['username']);
510 | }
511 | throw new InvalidRevisionException($revision);
512 | }
513 |
514 | /**
515 | * Find all revisions that were made of entity class with given id.
516 | *
517 | * @param string $className
518 | * @param int|string|array $id
519 | *
520 | * @throws Exception
521 | * @throws NotAuditedException
522 | *
523 | * @return Revision[]
524 | *
525 | * @phpstan-param class-string $className
526 | */
527 | public function findRevisions($className, $id)
528 | {
529 | if (!$this->metadataFactory->isAudited($className)) {
530 | throw new NotAuditedException($className);
531 | }
532 |
533 | $classMetadata = $this->em->getClassMetadata($className);
534 | $tableName = $this->config->getTableName($classMetadata);
535 |
536 | if (!\is_array($id)) {
537 | $id = [$classMetadata->identifier[0] => $id];
538 | }
539 |
540 | $whereSQL = '';
541 | foreach ($classMetadata->identifier as $idField) {
542 | if (isset($classMetadata->fieldMappings[$idField])) {
543 | if ('' !== $whereSQL) {
544 | $whereSQL .= ' AND ';
545 | }
546 | $whereSQL .= 'e.'.self::getMappingColumnNameValue($classMetadata->fieldMappings[$idField]).' = ?';
547 | } elseif (isset($classMetadata->associationMappings[$idField]['joinColumns'])) {
548 | if ('' !== $whereSQL) {
549 | $whereSQL .= ' AND ';
550 | }
551 | $whereSQL .= 'e.'.$classMetadata->associationMappings[$idField]['joinColumns'][0]['name'].' = ?';
552 | }
553 | }
554 |
555 | $query = \sprintf(
556 | 'SELECT r.* FROM %s r INNER JOIN %s e ON r.id = e.%s WHERE %s ORDER BY r.id DESC',
557 | $this->config->getRevisionTableName(),
558 | $tableName,
559 | $this->config->getRevisionFieldName(),
560 | $whereSQL
561 | );
562 | $revisionsData = $this->em->getConnection()->fetchAllAssociative($query, array_values($id));
563 |
564 | $revisions = [];
565 | foreach ($revisionsData as $row) {
566 | $timestamp = \DateTime::createFromFormat($this->platform->getDateTimeFormatString(), $row['timestamp']);
567 | \assert(false !== $timestamp);
568 |
569 | $revisions[] = new Revision($row['id'], $timestamp, $row['username']);
570 | }
571 |
572 | return $revisions;
573 | }
574 |
575 | /**
576 | * NEXT_MAJOR: Add NoRevisionFoundException as possible exception.
577 | * Gets the current revision of the entity with given ID.
578 | *
579 | * @param string $className
580 | * @param int|string|array $id
581 | *
582 | * @throws Exception
583 | * @throws NotAuditedException
584 | *
585 | * @return int|string|null
586 | *
587 | * @phpstan-param class-string $className
588 | */
589 | public function getCurrentRevision($className, $id)
590 | {
591 | if (!$this->metadataFactory->isAudited($className)) {
592 | throw new NotAuditedException($className);
593 | }
594 |
595 | $classMetadata = $this->em->getClassMetadata($className);
596 | $tableName = $this->config->getTableName($classMetadata);
597 |
598 | if (!\is_array($id)) {
599 | $id = [$classMetadata->identifier[0] => $id];
600 | }
601 |
602 | $whereSQL = '';
603 | foreach ($classMetadata->identifier as $idField) {
604 | if (isset($classMetadata->fieldMappings[$idField])) {
605 | if ('' !== $whereSQL) {
606 | $whereSQL .= ' AND ';
607 | }
608 | $whereSQL .= 'e.'.self::getMappingColumnNameValue($classMetadata->fieldMappings[$idField]).' = ?';
609 | } elseif (isset($classMetadata->associationMappings[$idField]['joinColumns'])) {
610 | if ('' !== $whereSQL) {
611 | $whereSQL .= ' AND ';
612 | }
613 | $whereSQL .= 'e.'.self::getMappingNameValue($classMetadata->associationMappings[$idField]['joinColumns'][0]).' = ?';
614 | }
615 | }
616 |
617 | $query = 'SELECT e.'.$this->config->getRevisionFieldName().' FROM '.$tableName.' e '.
618 | ' WHERE '.$whereSQL.' ORDER BY e.'.$this->config->getRevisionFieldName().' DESC';
619 |
620 | $revision = $this->em->getConnection()->fetchOne($query, array_values($id));
621 |
622 | if (false === $revision) {
623 | // NEXT_MAJOR: Remove next line and uncomment the following one, also remove "null" as possible return type.
624 | return null;
625 | // throw new NoRevisionFoundException($className, $id, null);
626 | }
627 |
628 | return $revision;
629 | }
630 |
631 | /**
632 | * Get an array with the differences of between two specific revisions of
633 | * an object with a given id.
634 | *
635 | * @param string $className
636 | * @param int|string $id
637 | * @param int|string $oldRevision
638 | * @param int|string $newRevision
639 | *
640 | * @throws DeletedException
641 | * @throws NoRevisionFoundException
642 | * @throws NotAuditedException
643 | * @throws Exception
644 | * @throws ORMException
645 | * @throws \RuntimeException
646 | *
647 | * @return array>
648 | *
649 | * @phpstan-param class-string $className
650 | * @phpstan-return array
651 | */
652 | public function diff($className, $id, $oldRevision, $newRevision)
653 | {
654 | $oldObject = $this->find($className, $id, $oldRevision);
655 | $newObject = $this->find($className, $id, $newRevision);
656 |
657 | $oldValues = null !== $oldObject ? $this->getEntityValues($className, $oldObject) : [];
658 | $newValues = null !== $newObject ? $this->getEntityValues($className, $newObject) : [];
659 |
660 | $differ = new ArrayDiff();
661 |
662 | return $differ->diff($oldValues, $newValues);
663 | }
664 |
665 | /**
666 | * Get the values for a specific entity as an associative array.
667 | *
668 | * @param string $className
669 | * @param object $entity
670 | *
671 | * @return array
672 | *
673 | * @phpstan-param class-string $className
674 | */
675 | public function getEntityValues($className, $entity)
676 | {
677 | $metadata = $this->em->getClassMetadata($className);
678 | $fields = $metadata->getFieldNames();
679 |
680 | $return = [];
681 | foreach ($fields as $fieldName) {
682 | $return[$fieldName] = $metadata->getFieldValue($entity, $fieldName);
683 | }
684 |
685 | return $return;
686 | }
687 |
688 | /**
689 | * @template T of object
690 | *
691 | * @param string $className
692 | * @param int|string|array $id
693 | *
694 | * @throws NoRevisionFoundException
695 | * @throws NotAuditedException
696 | * @throws Exception
697 | * @throws ORMException
698 | * @throws DeletedException
699 | *
700 | * @return array
701 | *
702 | * @phpstan-param class-string $className
703 | * @phpstan-return array
704 | */
705 | public function getEntityHistory($className, $id)
706 | {
707 | if (!$this->metadataFactory->isAudited($className)) {
708 | throw new NotAuditedException($className);
709 | }
710 |
711 | /** @var ClassMetadata $classMetadata */
712 | $classMetadata = $this->em->getClassMetadata($className);
713 | $tableName = $this->config->getTableName($classMetadata);
714 |
715 | if (!\is_array($id)) {
716 | $id = [$classMetadata->identifier[0] => $id];
717 | }
718 |
719 | /** @phpstan-var array $whereId */
720 | $whereId = [];
721 | foreach ($classMetadata->identifier as $idField) {
722 | if (isset($classMetadata->fieldMappings[$idField])) {
723 | $columnName = self::getMappingColumnNameValue($classMetadata->fieldMappings[$idField]);
724 | } elseif (isset($classMetadata->associationMappings[$idField]['joinColumns'])) {
725 | $columnName = self::getMappingNameValue($classMetadata->associationMappings[$idField]['joinColumns'][0]);
726 | } else {
727 | continue;
728 | }
729 |
730 | $whereId[] = "{$columnName} = ?";
731 | }
732 |
733 | $whereSQL = implode(' AND ', $whereId);
734 | $columnList = [$this->config->getRevisionFieldName()];
735 | $columnMap = [];
736 |
737 | foreach ($classMetadata->fieldNames as $columnName => $field) {
738 | $type = Type::getType(self::getMappingValue($classMetadata->fieldMappings[$field], 'type'));
739 | /** @phpstan-var literal-string $sqlExpr */
740 | $sqlExpr = $type->convertToPHPValueSQL(
741 | $this->quoteStrategy->getColumnName($field, $classMetadata, $this->platform),
742 | $this->platform
743 | );
744 | /** @phpstan-var literal-string $quotedField */
745 | $quotedField = $this->platform->quoteSingleIdentifier($field);
746 | $columnList[] = $sqlExpr.' AS '.$quotedField;
747 | $columnMap[$field] = $this->getSQLResultCasing($this->platform, $columnName);
748 | }
749 |
750 | foreach ($classMetadata->associationMappings as $assoc) {
751 | if (!self::isToOneOwningSide($assoc)) {
752 | continue;
753 | }
754 |
755 | foreach (self::getTargetToSourceKeyColumns($assoc) as $sourceCol) {
756 | $columnList[] = $sourceCol;
757 | $columnMap[$sourceCol] = $this->getSQLResultCasing($this->platform, $sourceCol);
758 | }
759 | }
760 |
761 | $values = array_values($id);
762 |
763 | $query =
764 | 'SELECT '.implode(', ', $columnList)
765 | .' FROM '.$tableName.' e'
766 | .' WHERE '.$whereSQL
767 | .' ORDER BY e.'.$this->config->getRevisionFieldName().' DESC';
768 |
769 | $stmt = $this->em->getConnection()->executeQuery($query, $values);
770 |
771 | $result = [];
772 | $row = $stmt->fetchAssociative();
773 | while (false !== $row) {
774 | $rev = $row[$this->config->getRevisionFieldName()];
775 | unset($row[$this->config->getRevisionFieldName()]);
776 | $result[] = $this->createEntity($classMetadata->name, $columnMap, $row, $rev);
777 |
778 | $row = $stmt->fetchAssociative();
779 | }
780 |
781 | return $result;
782 | }
783 |
784 | /**
785 | * @param string $className
786 | *
787 | * @return EntityPersister
788 | *
789 | * @phpstan-param class-string $className
790 | */
791 | protected function getEntityPersister($className)
792 | {
793 | $uow = $this->em->getUnitOfWork();
794 |
795 | return $uow->getEntityPersister($className);
796 | }
797 |
798 | /**
799 | * Simplified and stolen code from UnitOfWork::createEntity.
800 | *
801 | * @template T of object
802 | *
803 | * @param string $className
804 | * @param array $columnMap
805 | * @param array $data
806 | * @param int|string $revision
807 | *
808 | * @throws DeletedException
809 | * @throws NoRevisionFoundException
810 | * @throws NotAuditedException
811 | * @throws Exception
812 | * @throws ORMException
813 | * @throws \RuntimeException
814 | *
815 | * @return object
816 | *
817 | * @phpstan-param class-string $className
818 | * @phpstan-return T
819 | */
820 | private function createEntity($className, array $columnMap, array $data, $revision)
821 | {
822 | $classMetadata = $this->em->getClassMetadata($className);
823 |
824 | // lookup revisioned entity cache
825 | $keyParts = [];
826 |
827 | foreach ($classMetadata->getIdentifierFieldNames() as $name) {
828 | $keyParts[] = $data[$name];
829 | }
830 |
831 | $key = implode(':', $keyParts);
832 |
833 | if (isset($this->entityCache[$className][$key][$revision])) {
834 | /** @phpstan-var T $cachedEntity */
835 | $cachedEntity = $this->entityCache[$className][$key][$revision];
836 |
837 | return $cachedEntity;
838 | }
839 |
840 | if (
841 | !$classMetadata->isInheritanceTypeNone()
842 | && null !== $classMetadata->discriminatorColumn
843 | ) {
844 | $discriminator = $data[self::getMappingNameValue($classMetadata->discriminatorColumn)];
845 | if (null === $discriminator || !isset($classMetadata->discriminatorMap[$discriminator])) {
846 | throw new \RuntimeException("No mapping found for [$discriminator].");
847 | }
848 |
849 | if (isset($classMetadata->discriminatorValue)) {
850 | /** @phpstan-var T $entity */
851 | $entity = $this->em->getClassMetadata($classMetadata->discriminatorMap[$discriminator])->newInstance();
852 | } else {
853 | // a complex case when ToOne binding is against AbstractEntity having no discriminator
854 | $pk = [];
855 |
856 | foreach ($classMetadata->identifier as $field) {
857 | if (isset($data[$field])) {
858 | $pk[$classMetadata->getColumnName($field)] = $data[$field];
859 | }
860 | }
861 |
862 | /** @phpstan-var class-string $classNameDiscriminator */
863 | $classNameDiscriminator = $classMetadata->discriminatorMap[$discriminator];
864 |
865 | /** @phpstan-var T $entity */
866 | $entity = $this->find($classNameDiscriminator, $pk, $revision);
867 |
868 | return $entity;
869 | }
870 | } else {
871 | /** @phpstan-var T $entity */
872 | $entity = $classMetadata->newInstance();
873 | }
874 |
875 | // cache the entity to prevent circular references
876 | $this->entityCache[$className][$key][$revision] = $entity;
877 |
878 | foreach ($data as $field => $value) {
879 | if (isset($classMetadata->fieldMappings[$field])) {
880 | $type = Type::getType(self::getMappingValue($classMetadata->fieldMappings[$field], 'type'));
881 | $value = $type->convertToPHPValue($value, $this->platform);
882 |
883 | $classMetadata->setFieldValue($entity, $field, $value);
884 | }
885 | }
886 |
887 | foreach ($classMetadata->associationMappings as $field => $assoc) {
888 | /** @phpstan-var class-string $targetEntity */
889 | $targetEntity = self::getMappingTargetEntityValue($assoc);
890 | $targetClass = $this->em->getClassMetadata($targetEntity);
891 |
892 | $mappedBy = $assoc['mappedBy'] ?? null;
893 |
894 | if (self::isToOne($assoc)) {
895 | if ($this->metadataFactory->isAudited($targetEntity)) {
896 | if ($this->loadAuditedEntities) {
897 | // Primary Key. Used for audit tables queries.
898 | $pk = [];
899 | // Primary Field. Used when fallback to Doctrine finder.
900 | $pf = [];
901 |
902 | if (self::isToOneOwningSide($assoc)) {
903 | foreach (self::getTargetToSourceKeyColumns($assoc) as $foreign => $local) {
904 | $key = $data[$columnMap[$local]];
905 | if (null === $key) {
906 | continue;
907 | }
908 |
909 | $pk[$foreign] = $key;
910 | $pf[$foreign] = $key;
911 | }
912 | } elseif (null !== $mappedBy) {
913 | $otherEntityAssoc = $this->em->getClassMetadata($targetEntity)
914 | ->associationMappings[$mappedBy];
915 |
916 | if (self::isToOneOwningSide($otherEntityAssoc)) {
917 | foreach (self::getTargetToSourceKeyColumns($otherEntityAssoc) as $local => $foreign) {
918 | $key = $data[$classMetadata->getFieldName($local)];
919 | if (null === $key) {
920 | continue;
921 | }
922 |
923 | $pk[$foreign] = $key;
924 | $pf[self::getMappingFieldNameValue($otherEntityAssoc)] = $key;
925 | }
926 | }
927 | }
928 |
929 | if ([] === $pk) {
930 | $value = null;
931 | } else {
932 | try {
933 | $value = $this->find(
934 | $targetClass->name,
935 | $pk,
936 | $revision,
937 | ['threatDeletionsAsExceptions' => true]
938 | );
939 | } catch (DeletedException) {
940 | $value = null;
941 | } catch (NoRevisionFoundException) {
942 | // The entity does not have any revision yet. So let's get the actual state of it.
943 | $value = $this->em->getRepository($targetClass->name)->findOneBy($pf);
944 | }
945 | }
946 | } else {
947 | $value = null;
948 | }
949 | } else {
950 | if ($this->loadNativeEntities) {
951 | if (self::isToOneOwningSide($assoc)) {
952 | $associatedId = [];
953 | foreach (self::getTargetToSourceKeyColumns($assoc) as $targetColumn => $srcColumn) {
954 | $joinColumnValue = $data[$columnMap[$srcColumn]] ?? null;
955 | if (null !== $joinColumnValue) {
956 | $targetField = $targetClass->fieldNames[$targetColumn];
957 | $joinColumnType = Type::getType(self::getMappingValue($targetClass->fieldMappings[$targetField], 'type'));
958 | $joinColumnValue = $joinColumnType->convertToPHPValue(
959 | $joinColumnValue,
960 | $this->platform
961 | );
962 | $associatedId[$targetField] = $joinColumnValue;
963 | }
964 | }
965 | if ([] === $associatedId) {
966 | // Foreign key is NULL
967 | $value = null;
968 | } else {
969 | $value = $this->em->getReference($targetClass->name, $associatedId);
970 | }
971 | } else {
972 | // Inverse side of x-to-one can never be lazy
973 | $value = $this->getEntityPersister($targetEntity)
974 | ->loadOneToOneEntity($assoc, $entity);
975 | }
976 | } else {
977 | $value = null;
978 | }
979 | }
980 |
981 | $classMetadata->setFieldValue($entity, $field, $value);
982 | } elseif (
983 | 0 !== ($assoc['type'] & ClassMetadata::ONE_TO_MANY)
984 | && null !== $mappedBy
985 | && isset($targetClass->associationMappings[$mappedBy]['sourceToTargetKeyColumns'])
986 | ) {
987 | if ($this->metadataFactory->isAudited($targetEntity)) {
988 | if ($this->loadAuditedCollections) {
989 | $foreignKeys = [];
990 | foreach ($targetClass->associationMappings[$mappedBy]['sourceToTargetKeyColumns'] as $local => $foreign) {
991 | $field = $classMetadata->getFieldForColumn($foreign);
992 | $foreignKeys[$local] = $classMetadata->getFieldValue($entity, $field);
993 | }
994 |
995 | $collection = new AuditedCollection(
996 | $this,
997 | $targetClass->name,
998 | $targetClass,
999 | $assoc,
1000 | $foreignKeys,
1001 | $revision
1002 | );
1003 | } else {
1004 | $collection = new ArrayCollection();
1005 | }
1006 | } else {
1007 | if ($this->loadNativeCollections) {
1008 | $collection = new PersistentCollection($this->em, $targetClass, new ArrayCollection());
1009 |
1010 | $this->getEntityPersister($targetEntity)
1011 | ->loadOneToManyCollection($assoc, $entity, $collection);
1012 | } else {
1013 | $collection = new ArrayCollection();
1014 | }
1015 | }
1016 |
1017 | $classMetadata->setFieldValue($entity, $assoc['fieldName'], $collection);
1018 | } elseif (self::isManyToMany($assoc)) {
1019 | if (self::isManyToManyOwningSideMapping($assoc)) {
1020 | $whereId = [$this->config->getRevisionFieldName().' = ?'];
1021 | $values = [$revision];
1022 | foreach (self::getRelationToSourceKeyColumns($assoc) as $sourceKeyJoinColumn => $sourceKeyColumn) {
1023 | $whereId[] = "{$sourceKeyJoinColumn} = ?";
1024 | $values[] = $classMetadata->getFieldValue($entity, 'id');
1025 | }
1026 |
1027 | $whereSQL = implode(' AND ', $whereId);
1028 | $columnList = [
1029 | $this->config->getRevisionFieldName(),
1030 | $this->config->getRevisionTypeFieldName(),
1031 | ];
1032 | $tableName = $this->config->getTablePrefix()
1033 | .self::getMappingJoinTableNameValue($assoc)
1034 | .$this->config->getTableSuffix();
1035 |
1036 | foreach (self::getRelationToTargetKeyColumns($assoc) as $targetKeyJoinColumn => $targetKeyColumn) {
1037 | $columnList[] = $targetKeyJoinColumn;
1038 | }
1039 |
1040 | $query = \sprintf(
1041 | 'SELECT %s FROM %s e WHERE %s ORDER BY e.%s DESC',
1042 | implode(', ', $columnList),
1043 | $tableName,
1044 | $whereSQL,
1045 | $this->config->getRevisionFieldName()
1046 | );
1047 |
1048 | $rows = $this->em->getConnection()->fetchAllAssociative($query, $values);
1049 |
1050 | /** @var ArrayCollection */
1051 | $collection = new ArrayCollection();
1052 | if (0 < \count($rows)) {
1053 | if ($this->metadataFactory->isAudited($targetEntity)) {
1054 | foreach ($rows as $row) {
1055 | $id = [];
1056 |
1057 | foreach (self::getRelationToTargetKeyColumns($assoc) as $targetKeyJoinColumn => $targetKeyColumn) {
1058 | $joinKey = $row[$targetKeyJoinColumn];
1059 | $id[$targetKeyColumn] = $joinKey;
1060 | }
1061 | try {
1062 | $object = $this->find($targetClass->getName(), $id, $revision);
1063 | } catch (NoRevisionFoundException) {
1064 | // The entity does not have any revision yet. So let's get the actual state of it.
1065 | $object = $this->em->getRepository($targetClass->getName())->findOneBy($id);
1066 | }
1067 | if (null !== $object) {
1068 | $collection->add($object);
1069 | }
1070 | }
1071 | } else {
1072 | if ($this->loadNativeCollections) {
1073 | $collection = new PersistentCollection(
1074 | $this->em,
1075 | $targetClass,
1076 | new ArrayCollection()
1077 | );
1078 |
1079 | $this->getEntityPersister($targetEntity)
1080 | ->loadManyToManyCollection($assoc, $entity, $collection);
1081 |
1082 | $classMetadata->setFieldValue($entity, $assoc['fieldName'], $collection);
1083 | } else {
1084 | $classMetadata->setFieldValue($entity, $assoc['fieldName'], new ArrayCollection());
1085 | }
1086 | }
1087 | }
1088 |
1089 | $classMetadata->setFieldValue($entity, $field, $collection);
1090 | } elseif (isset($targetClass->associationMappings[$mappedBy])) {
1091 | $targetAssoc = $targetClass->associationMappings[$mappedBy];
1092 | $whereId = [$this->config->getRevisionFieldName().' = ?'];
1093 | $values = [$revision];
1094 |
1095 | /** @var ArrayCollection */
1096 | $collection = new ArrayCollection();
1097 |
1098 | // if the owning side of the relation is audited, fetch the audited values, otherwise fetch
1099 | // data from the main table
1100 | if ($this->metadataFactory->isAudited($assoc['targetEntity'])
1101 | && isset(
1102 | $targetAssoc['relationToSourceKeyColumns'],
1103 | $targetAssoc['relationToSourceKeyColumns'],
1104 | $targetAssoc['joinTable']['name'],
1105 | $targetAssoc['relationToTargetKeyColumns']
1106 | )) {
1107 | foreach ($targetAssoc['relationToTargetKeyColumns'] as $targetKeyJoinColumn => $targetKeyColumn) {
1108 | $whereId[] = "{$targetKeyJoinColumn} = ?";
1109 | $values[] = $classMetadata->getFieldValue($entity, 'id');
1110 | }
1111 |
1112 | $whereSQL = implode(' AND ', $whereId);
1113 | $columnList = [
1114 | $this->config->getRevisionFieldName(),
1115 | $this->config->getRevisionTypeFieldName(),
1116 | ];
1117 |
1118 | $tableName = $this->config->getTablePrefix()
1119 | .$targetAssoc['joinTable']['name']
1120 | .$this->config->getTableSuffix();
1121 |
1122 | foreach ($targetAssoc['relationToSourceKeyColumns'] as $sourceKeyJoinColumn => $sourceKeyColumn) {
1123 | $columnList[] = $sourceKeyJoinColumn;
1124 | }
1125 | $query = \sprintf(
1126 | 'SELECT %s FROM %s e WHERE %s ORDER BY e.%s DESC',
1127 | implode(', ', $columnList),
1128 | $tableName,
1129 | $whereSQL,
1130 | $this->config->getRevisionFieldName()
1131 | );
1132 |
1133 | $rows = $this->em->getConnection()->fetchAllAssociative($query, $values);
1134 |
1135 | if (0 < \count($rows)) {
1136 | foreach ($rows as $row) {
1137 | $id = [];
1138 | /** @phpstan-var string $sourceKeyColumn */
1139 | foreach ($targetAssoc['relationToSourceKeyColumns'] as $sourceKeyJoinColumn => $sourceKeyColumn) {
1140 | $joinKey = $row[$sourceKeyJoinColumn];
1141 | $id[$sourceKeyColumn] = $joinKey;
1142 | }
1143 |
1144 | try {
1145 | $object = $this->find($targetClass->getName(), $id, $revision);
1146 | } catch (NoRevisionFoundException) {
1147 | // The entity does not have any revision yet. So let's get the actual state of it.
1148 | $object = $this->em->getRepository($targetClass->getName())->findOneBy($id);
1149 | }
1150 | if (null !== $object) {
1151 | $collection->add($object);
1152 | }
1153 | }
1154 | }
1155 | } else {
1156 | if ($this->loadNativeCollections) {
1157 | $collection = new PersistentCollection(
1158 | $this->em,
1159 | $targetClass,
1160 | new ArrayCollection()
1161 | );
1162 |
1163 | $this->getEntityPersister($assoc['targetEntity'])
1164 | ->loadManyToManyCollection($assoc, $entity, $collection);
1165 |
1166 | $classMetadata->setFieldValue($entity, $assoc['fieldName'], $collection);
1167 | } else {
1168 | $classMetadata->setFieldValue($entity, $assoc['fieldName'], new ArrayCollection());
1169 | }
1170 | }
1171 |
1172 | $classMetadata->setFieldValue($entity, $field, $collection);
1173 | }
1174 | } else {
1175 | // Inject collection
1176 | $classMetadata->setFieldValue($entity, $field, new ArrayCollection());
1177 | }
1178 | }
1179 |
1180 | return $entity;
1181 | }
1182 | }
1183 |
--------------------------------------------------------------------------------