├── 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 | 11 | 12 | 13 | 14 | 15 | {% for changedEntity in changedEntities %} 16 | 17 | 18 | 19 | 20 | 21 | {% endfor %} 22 | 23 |
Class NameIdentifiersRevision Type
{{ changedEntity.className }}{{ changedEntity.id | join(', ') }}{{ changedEntity.revisionType }}
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 | 10 | 11 | 12 | 13 | {% for field, value in data %} 14 | 15 | 16 | {% if value.timestamp is defined %} 17 | 18 | {% elseif value is iterable %} 19 | 26 | {% else %} 27 | 28 | {% endif %} 29 | 30 | {% endfor %} 31 | 32 |
FieldValue
{{ field }}{{ value|date('m/d/Y') }} 20 |
    21 | {% for element in value %} 22 |
  • {{ element }}
  • 23 | {% endfor %} 24 |
25 |
{{ value }}
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 | 25 | 26 | 27 | 28 | 29 | 30 | {% for field, value in diff %} 31 | 32 | 33 | 36 | 39 | 42 | 43 | {% endfor %} 44 | 45 |
FieldDeletedSameUpdated
{{ field }} 34 | {{ helper.showValue(value.old) }} 35 | 37 | {{ helper.showValue(value.same) }} 38 | 40 | {{ helper.showValue(value.new) }} 41 |
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 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {% for revision in revisions %} 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | {% endfor %} 35 | 36 |
 Compare
RevisionDateUserOldNew
{{ revision.rev }}{{ revision.timestamp | date('r') }}{{ revision.username|default('Anonymous') }}
37 | 38 | 39 |
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 | --------------------------------------------------------------------------------