diff --git a/.github/workflows/phpunit.yaml b/.github/workflows/phpunit.yaml
index 2ca68f7..8c4f8ab 100644
--- a/.github/workflows/phpunit.yaml
+++ b/.github/workflows/phpunit.yaml
@@ -9,6 +9,11 @@ on:
jobs:
symfony-tests:
runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ dbal: ['^3.8', '^4.0']
+ name: PHPUnit (DBAL ${{ matrix.dbal }})
steps:
- name: Runs Elasticsearch
run: |
@@ -30,11 +35,13 @@ jobs:
uses: actions/cache@v3
with:
path: vendor
- key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
+ key: ${{ runner.os }}-php-dbal${{ matrix.dbal }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
- ${{ runner.os }}-php-
+ ${{ runner.os }}-php-dbal${{ matrix.dbal }}-
- name: Pin Elasticsearch client to the server major version
run: composer require --no-update --no-interaction "elasticsearch/elasticsearch:^8.0"
+ - name: Pin Doctrine DBAL to the matrix major version
+ run: composer require --no-update --no-interaction "doctrine/dbal:${{ matrix.dbal }}"
- name: Install Dependencies
run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
- name: Create Database
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d4d9813..7027513 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Added
+- `storage` configuration option to select the storage backend: `elasticsearch` (default), `doctrine` or `in_memory`; when a non-Elasticsearch backend is selected, no Elasticsearch services are registered
+- Doctrine DBAL storage adapter in `Locastic\Loggastic\Bridge\Doctrine\Storage`: stores activity logs and current data trackers in two shared database tables with JSON columns, works with any DBAL-supported database (PostgreSQL, MySQL, SQLite, ...) and requires no Elasticsearch; each object keeps a single current data tracker row, so repeated saves (message retries, re-running the populate command) update it instead of failing
+- In-memory storage adapter in `Locastic\Loggastic\Storage\InMemory` for running the full logging flow in test suites without an external service
+- `doctrine/dbal` (`^3.8 || ^4.0`) added as an explicit dependency
+
## [2.0.0] - 2026-07-03
Major release: pluggable storage. Elasticsearch is now one storage backend
diff --git a/README.md b/README.md
index 890ce37..e709dec 100644
--- a/README.md
+++ b/README.md
@@ -15,25 +15,40 @@ Loggastic
Loggastic is made for tracking changes to your objects and their relations.
-Built on top of the **Symfony framework**, this library makes it easy to implement activity logs and store them on **Elasticsearch** for fast logs browsing.
+Built on top of the **Symfony framework**, this library makes it easy to implement activity logs and store them in **Elasticsearch** or in your **relational database** (via Doctrine DBAL).
-Each tracked entity will have two indexes in the ElasticSearch:
-1. `entity_name_activity_log` -> saving all CRUD actions made on an object. And additionally saving before and after values for Edit actions.
-2. `entity_name_current_data_tracker` -> saving the latest object values used for comparing the changes made on Edit actions. This enables us to only store before and after values for modified fields in the `activity_log` index
+Two kinds of records are stored for each tracked entity:
+1. **Activity logs** -> saving all CRUD actions made on an object. And additionally saving before and after values for Edit actions.
+2. **Current data trackers** -> saving the latest object values used for comparing the changes made on Edit actions. This enables us to only store before and after values for modified fields in the activity logs.
System requirements
-------------------
- PHP 8.2+ with Symfony 6.4, 7.x or 8.x (Symfony 8 requires PHP 8.4)
- Doctrine ORM 3.4+ with DoctrineBundle 2.8+ or 3.x
-- Elasticsearch 8 or 9
+- A storage backend: Elasticsearch 8 or 9 (default), or any relational database supported by Doctrine DBAL
Installation
------------
`composer require locastic/loggastic`
-Requirements: Elasticsearch 8 or 9, and a PSR-18 HTTP client implementation for the Elasticsearch client (for example `composer require symfony/http-client nyholm/psr7`).
+Choose your storage
+-------------------
+
+Activity logs are stored in **Elasticsearch** by default, which is a great fit for fast log browsing on high-traffic data. Requirements: Elasticsearch 8 or 9, and a PSR-18 HTTP client implementation for the Elasticsearch client (for example `composer require symfony/http-client nyholm/psr7`). Each loggable entity gets two indexes: `entity_name_activity_log` and `entity_name_current_data_tracker`.
+
+If you don't want to run an Elasticsearch cluster, store the logs in your existing **relational database** instead (PostgreSQL, MySQL, SQLite, or anything else supported by Doctrine DBAL):
+
+```yaml
+# config/packages/loggastic.yaml
+locastic_loggastic:
+ storage: doctrine
+```
+
+The Doctrine storage uses your default DBAL connection and keeps all activity logs in two shared tables (`loggastic_activity_log` and `loggastic_current_data_tracker`), with JSON columns for changes data. Timestamps are stored in UTC. No Elasticsearch dependency or configuration is needed.
+
+For test suites there is also an `in_memory` storage that keeps logs in the PHP process, so the full logging flow runs without any external service.
Making your entity loggable
---------------------------
@@ -128,7 +143,9 @@ class Tag
Note: You can also use **annotations, xml and yaml**! Examples coming soon.
-### 3. Run commands for creating indexes in ElasticSearch
+### 3. Initialize the storage
+
+Create the Elasticsearch indexes or database tables for your loggable classes:
bin/console locastic:activity-logs:create-loggable-indexes
@@ -201,7 +218,7 @@ Each time some change happens in the database for loggable entities, the activit
Now that you have the basic setup, you can add some additional options and customize the library to your needs.
### Custom storage backends
-Activity logs are stored in Elasticsearch by default, but the core services only talk to three storage interfaces:
+The built-in backends are selected with the `storage` config option (`elasticsearch`, `doctrine` or `in_memory`). The core services only talk to three storage interfaces:
```php
Locastic\Loggastic\Storage\ActivityLogStorageInterface # writes and reads activity logs
@@ -226,6 +243,9 @@ Default configuration:
```yaml
# config/packages/loggastic.yaml
locastic_loggastic:
+ # storage backend for activity logs: 'elasticsearch', 'doctrine' or 'in_memory'
+ storage: elasticsearch
+
# directory paths containing loggable classes or xml/yaml files
loggable_paths:
- '%kernel.project_dir%/Resources/config/loggastic'
@@ -239,7 +259,7 @@ locastic_loggastic:
# if set to `false` default numeric array keys will be used
identifier_extractor: true
- # ElasticSearch config
+ # ElasticSearch config (only used when storage is 'elasticsearch')
elastic_host: 'localhost:9200'
elastic_user: null # basic auth username, for secured clusters
elastic_password: null # basic auth password, for secured clusters
diff --git a/composer.json b/composer.json
index cc6d35b..6b32970 100644
--- a/composer.json
+++ b/composer.json
@@ -1,6 +1,6 @@
{
"name": "locastic/loggastic",
- "description": "ElasticSearch activity logs for Symfony",
+ "description": "Activity logs for Symfony, stored in Elasticsearch or your relational database",
"license": "MIT",
"type": "symfony-bundle",
"authors": [
@@ -11,6 +11,7 @@
],
"require": {
"php": ">=8.2",
+ "doctrine/dbal": "^3.8 || ^4.0",
"doctrine/doctrine-bundle": "^2.8 || ^3.0",
"doctrine/orm": "^3.4",
"elasticsearch/elasticsearch": "^8.0 || ^9.0",
diff --git a/config/definition.php b/config/definition.php
index 3b8b689..73e7f94 100644
--- a/config/definition.php
+++ b/config/definition.php
@@ -30,6 +30,11 @@
$definition->rootNode()
->children()
+ ->enumNode('storage')
+ ->info('Storage backend for activity logs and current data trackers.')
+ ->values(['elasticsearch', 'doctrine', 'in_memory'])
+ ->defaultValue('elasticsearch')
+ ->end()
->booleanNode('default_doctrine_subscriber')
->defaultTrue()
->end()
diff --git a/config/storage_doctrine.yaml b/config/storage_doctrine.yaml
new file mode 100644
index 0000000..b32d0a8
--- /dev/null
+++ b/config/storage_doctrine.yaml
@@ -0,0 +1,18 @@
+services:
+ Locastic\Loggastic\Bridge\Doctrine\Storage\DoctrineActivityLogStorage:
+ autowire: true
+
+ Locastic\Loggastic\Bridge\Doctrine\Storage\DoctrineCurrentDataTrackerStorage:
+ autowire: true
+
+ Locastic\Loggastic\Bridge\Doctrine\Storage\DoctrineStorageInitializer:
+ autowire: true
+
+ Locastic\Loggastic\Storage\ActivityLogStorageInterface:
+ alias: 'Locastic\Loggastic\Bridge\Doctrine\Storage\DoctrineActivityLogStorage'
+
+ Locastic\Loggastic\Storage\CurrentDataTrackerStorageInterface:
+ alias: 'Locastic\Loggastic\Bridge\Doctrine\Storage\DoctrineCurrentDataTrackerStorage'
+
+ Locastic\Loggastic\Storage\StorageInitializerInterface:
+ alias: 'Locastic\Loggastic\Bridge\Doctrine\Storage\DoctrineStorageInitializer'
diff --git a/config/storage_in_memory.yaml b/config/storage_in_memory.yaml
new file mode 100644
index 0000000..1773fd6
--- /dev/null
+++ b/config/storage_in_memory.yaml
@@ -0,0 +1,16 @@
+services:
+ Locastic\Loggastic\Storage\InMemory\InMemoryActivityLogStorage: ~
+
+ Locastic\Loggastic\Storage\InMemory\InMemoryCurrentDataTrackerStorage: ~
+
+ Locastic\Loggastic\Storage\InMemory\InMemoryStorageInitializer:
+ autowire: true
+
+ Locastic\Loggastic\Storage\ActivityLogStorageInterface:
+ alias: 'Locastic\Loggastic\Storage\InMemory\InMemoryActivityLogStorage'
+
+ Locastic\Loggastic\Storage\CurrentDataTrackerStorageInterface:
+ alias: 'Locastic\Loggastic\Storage\InMemory\InMemoryCurrentDataTrackerStorage'
+
+ Locastic\Loggastic\Storage\StorageInitializerInterface:
+ alias: 'Locastic\Loggastic\Storage\InMemory\InMemoryStorageInitializer'
diff --git a/src/Bridge/Doctrine/Storage/DoctrineActivityLogStorage.php b/src/Bridge/Doctrine/Storage/DoctrineActivityLogStorage.php
new file mode 100644
index 0000000..c320476
--- /dev/null
+++ b/src/Bridge/Doctrine/Storage/DoctrineActivityLogStorage.php
@@ -0,0 +1,112 @@
+ 'logged_at', 'action' => 'action', 'objectId' => 'object_id'];
+
+ public function __construct(
+ private readonly Connection $connection,
+ private readonly string $table = self::DEFAULT_TABLE,
+ ) {
+ }
+
+ public function save(ActivityLogInputInterface $activityLog, string $className): void
+ {
+ $this->connection->insert($this->table, [
+ 'object_id' => $activityLog->getObjectId(),
+ 'object_class' => $className,
+ 'action' => $activityLog->getAction(),
+ 'logged_at' => \DateTimeImmutable::createFromMutable($activityLog->getLoggedAt())->setTimezone(new \DateTimeZone('UTC')),
+ 'data_changes' => $activityLog->getDataChanges(),
+ 'request_url' => $activityLog->getRequestUrl(),
+ 'user_data' => null !== $activityLog->getUser() ? json_encode($activityLog->getUser(), JSON_THROW_ON_ERROR) : null,
+ ], [
+ 'logged_at' => Types::DATETIME_IMMUTABLE,
+ ]);
+ }
+
+ /**
+ * @param array $sort
+ *
+ * @return array
+ */
+ public function findByClass(string $className, array $sort = [], int $limit = 20, int $offset = 0): array
+ {
+ return $this->query($className, null, $sort, $limit, $offset);
+ }
+
+ /**
+ * @param array $sort
+ *
+ * @return array
+ */
+ public function findByClassAndObjectId(string $className, mixed $objectId, array $sort = [], int $limit = 20, int $offset = 0): array
+ {
+ return $this->query($className, $objectId, $sort, $limit, $offset);
+ }
+
+ /**
+ * @param array $sort
+ *
+ * @return array
+ */
+ private function query(string $className, mixed $objectId, array $sort, int $limit, int $offset): array
+ {
+ $queryBuilder = $this->connection->createQueryBuilder()
+ ->select('*')
+ ->from($this->table)
+ ->where('object_class = :objectClass')
+ ->setParameter('objectClass', $className)
+ ->setMaxResults($limit)
+ ->setFirstResult($offset);
+
+ if (null !== $objectId) {
+ $queryBuilder->andWhere('object_id = :objectId')->setParameter('objectId', (string) $objectId);
+ }
+
+ foreach ($sort as $field => $direction) {
+ if (isset(self::SORTABLE_FIELDS[$field])) {
+ $queryBuilder->addOrderBy(self::SORTABLE_FIELDS[$field], 'desc' === strtolower((string) $direction) ? 'DESC' : 'ASC');
+ }
+ }
+
+ // deterministic order and tiebreak, insertion order matches ES behavior
+ $queryBuilder->addOrderBy('id', 'ASC');
+
+ return array_map($this->hydrate(...), $queryBuilder->executeQuery()->fetchAllAssociative());
+ }
+
+ /**
+ * @param array $row
+ */
+ private function hydrate(array $row): ActivityLog
+ {
+ $activityLog = new ActivityLog();
+ $activityLog->setId((string) $row['id']);
+ $activityLog->setAction($row['action']);
+ $activityLog->setLoggedAt(new \DateTime($row['logged_at'], new \DateTimeZone('UTC')));
+ $activityLog->setObjectId($row['object_id']);
+ $activityLog->setObjectClass($row['object_class']);
+ $activityLog->setDataChanges($row['data_changes']);
+ $activityLog->setUser(null !== $row['user_data'] ? json_decode($row['user_data'], true, 512, JSON_THROW_ON_ERROR) : null);
+ $activityLog->setRequestUrl($row['request_url']);
+
+ return $activityLog;
+ }
+}
diff --git a/src/Bridge/Doctrine/Storage/DoctrineCurrentDataTrackerStorage.php b/src/Bridge/Doctrine/Storage/DoctrineCurrentDataTrackerStorage.php
new file mode 100644
index 0000000..01d196f
--- /dev/null
+++ b/src/Bridge/Doctrine/Storage/DoctrineCurrentDataTrackerStorage.php
@@ -0,0 +1,107 @@
+connection->createQueryBuilder()
+ ->select('id')
+ ->from($this->table)
+ ->where('object_class = :objectClass')
+ ->andWhere('object_id = :objectId')
+ ->setParameter('objectClass', $className)
+ ->setParameter('objectId', (string) $currentDataTracker->getObjectId())
+ ->executeQuery()
+ ->fetchOne();
+
+ if (false !== $existingId) {
+ $this->update($existingId, $currentDataTracker, $className);
+
+ return;
+ }
+
+ $this->connection->insert($this->table, [
+ 'object_id' => $currentDataTracker->getObjectId(),
+ 'object_class' => $className,
+ 'date_time' => \DateTimeImmutable::createFromMutable($currentDataTracker->getDateTime())->setTimezone(new \DateTimeZone('UTC')),
+ 'data' => $currentDataTracker->getData(),
+ ], [
+ 'date_time' => Types::DATETIME_IMMUTABLE,
+ ]);
+ }
+
+ public function update(mixed $id, CurrentDataTrackerInputInterface $currentDataTracker, string $className): void
+ {
+ $this->connection->update($this->table, [
+ 'date_time' => \DateTimeImmutable::createFromMutable($currentDataTracker->getDateTime())->setTimezone(new \DateTimeZone('UTC')),
+ 'data' => $currentDataTracker->getData(),
+ ], [
+ 'id' => $id,
+ 'object_class' => $className,
+ ], [
+ 'date_time' => Types::DATETIME_IMMUTABLE,
+ ]);
+ }
+
+ /**
+ * @param array $currentDataTrackers
+ */
+ public function bulkSave(array $currentDataTrackers, string $className): void
+ {
+ $this->connection->transactional(function () use ($currentDataTrackers, $className): void {
+ foreach ($currentDataTrackers as $currentDataTracker) {
+ $this->save($currentDataTracker, $className);
+ }
+ });
+ }
+
+ public function findByClassAndObjectId(string $className, mixed $objectId): ?CurrentDataTrackerInterface
+ {
+ $row = $this->connection->createQueryBuilder()
+ ->select('*')
+ ->from($this->table)
+ ->where('object_class = :objectClass')
+ ->andWhere('object_id = :objectId')
+ ->setParameter('objectClass', $className)
+ ->setParameter('objectId', (string) $objectId)
+ ->executeQuery()
+ ->fetchAssociative();
+
+ if (false === $row) {
+ return null;
+ }
+
+ $currentDataTracker = new CurrentDataTracker();
+ $currentDataTracker->setId((string) $row['id']);
+ $currentDataTracker->setObjectId($row['object_id']);
+ $currentDataTracker->setObjectClass($row['object_class']);
+ $currentDataTracker->setDateTime(new \DateTime($row['date_time'], new \DateTimeZone('UTC')));
+ $currentDataTracker->setData($row['data'] ?? '[]');
+
+ return $currentDataTracker;
+ }
+}
diff --git a/src/Bridge/Doctrine/Storage/DoctrineStorageInitializer.php b/src/Bridge/Doctrine/Storage/DoctrineStorageInitializer.php
new file mode 100644
index 0000000..1a1c0c7
--- /dev/null
+++ b/src/Bridge/Doctrine/Storage/DoctrineStorageInitializer.php
@@ -0,0 +1,84 @@
+tableExists($this->activityLogTable)) {
+ return false;
+ }
+
+ $table = new Table($this->activityLogTable);
+ $table->addColumn('id', Types::BIGINT, ['autoincrement' => true]);
+ $table->addColumn('object_id', Types::STRING, ['length' => 255]);
+ $table->addColumn('object_class', Types::STRING, ['length' => 255]);
+ $table->addColumn('action', Types::STRING, ['length' => 255, 'notnull' => false]);
+ $table->addColumn('logged_at', Types::DATETIME_IMMUTABLE);
+ $table->addColumn('data_changes', Types::JSON, ['notnull' => false]);
+ $table->addColumn('request_url', Types::TEXT, ['notnull' => false]);
+ $table->addColumn('user_data', Types::JSON, ['notnull' => false]);
+ $table->setPrimaryKey(['id']);
+ $table->addIndex(['object_class', 'object_id'], 'idx_'.$this->activityLogTable.'_object');
+ $table->addIndex(['object_class', 'logged_at'], 'idx_'.$this->activityLogTable.'_logged_at');
+
+ $this->connection->createSchemaManager()->createTable($table);
+
+ return true;
+ }
+
+ public function initializeCurrentDataTrackerStorage(string $className): bool
+ {
+ if ($this->tableExists($this->currentDataTrackerTable)) {
+ return false;
+ }
+
+ $table = new Table($this->currentDataTrackerTable);
+ $table->addColumn('id', Types::BIGINT, ['autoincrement' => true]);
+ $table->addColumn('object_id', Types::STRING, ['length' => 255]);
+ $table->addColumn('object_class', Types::STRING, ['length' => 255]);
+ $table->addColumn('date_time', Types::DATETIME_IMMUTABLE);
+ $table->addColumn('data', Types::JSON, ['notnull' => false]);
+ $table->setPrimaryKey(['id']);
+ $table->addUniqueIndex(['object_class', 'object_id'], 'uniq_'.$this->currentDataTrackerTable.'_object');
+
+ $this->connection->createSchemaManager()->createTable($table);
+
+ return true;
+ }
+
+ public function recreateActivityLogStorage(string $className): void
+ {
+ $this->initializeActivityLogStorage($className);
+ $this->connection->delete($this->activityLogTable, ['object_class' => $className]);
+ }
+
+ public function recreateCurrentDataTrackerStorage(string $className): void
+ {
+ $this->initializeCurrentDataTrackerStorage($className);
+ $this->connection->delete($this->currentDataTrackerTable, ['object_class' => $className]);
+ }
+
+ private function tableExists(string $table): bool
+ {
+ return $this->connection->createSchemaManager()->tablesExist([$table]);
+ }
+}
diff --git a/src/LocasticLoggasticBundle.php b/src/LocasticLoggasticBundle.php
index 749ca41..287431d 100644
--- a/src/LocasticLoggasticBundle.php
+++ b/src/LocasticLoggasticBundle.php
@@ -33,17 +33,7 @@ public function loadExtension(array $config, ContainerConfigurator $container, C
$builder->setParameter('locastic_activity_log.identifier_extractor', $config['identifier_extractor'] ?? true);
- $builder->setParameter('locastic_activity_log.elasticsearch_host', $config['elastic_host']);
- $builder->setParameter('locastic_activity_log.elasticsearch_user', $config['elastic_user']);
- $builder->setParameter('locastic_activity_log.elasticsearch_password', $config['elastic_password']);
- $builder->setParameter('locastic_activity_log.elasticsearch_ssl_verification', $config['elastic_ssl_verification']);
- $builder->setParameter('locastic_activity_log.elastic_date_detection', $config['elastic_date_detection']);
- $builder->setParameter('locastic_activity_log.elastic_dynamic_date_formats', $config['elastic_dynamic_date_formats']);
-
- $builder->setParameter('locastic_activity_log.activity_log.elastic_properties', $config['activity_log']['elastic_properties']);
- $builder->setParameter('locastic_activity_log.current_data_tracker.elastic_properties', $config['current_data_tracker']['elastic_properties']);
-
- $container->import('../config/elastic.yaml');
+ $this->loadStorage($config, $container, $builder);
// load loggable resources
$loggableClasses = $this->getLoggablePaths($builder, $config);
@@ -60,6 +50,35 @@ public function loadExtension(array $config, ContainerConfigurator $container, C
}
}
+ /**
+ * @param array $config
+ */
+ private function loadStorage(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
+ {
+ switch ($config['storage']) {
+ case 'doctrine':
+ $container->import('../config/storage_doctrine.yaml');
+
+ return;
+ case 'in_memory':
+ $container->import('../config/storage_in_memory.yaml');
+
+ return;
+ }
+
+ $builder->setParameter('locastic_activity_log.elasticsearch_host', $config['elastic_host']);
+ $builder->setParameter('locastic_activity_log.elasticsearch_user', $config['elastic_user']);
+ $builder->setParameter('locastic_activity_log.elasticsearch_password', $config['elastic_password']);
+ $builder->setParameter('locastic_activity_log.elasticsearch_ssl_verification', $config['elastic_ssl_verification']);
+ $builder->setParameter('locastic_activity_log.elastic_date_detection', $config['elastic_date_detection']);
+ $builder->setParameter('locastic_activity_log.elastic_dynamic_date_formats', $config['elastic_dynamic_date_formats']);
+
+ $builder->setParameter('locastic_activity_log.activity_log.elastic_properties', $config['activity_log']['elastic_properties']);
+ $builder->setParameter('locastic_activity_log.current_data_tracker.elastic_properties', $config['current_data_tracker']['elastic_properties']);
+
+ $container->import('../config/elastic.yaml');
+ }
+
private function getLoggablePaths(ContainerBuilder $builder, array $config): array
{
$loggableClasses = ['yml' => [], 'xml' => [], 'dir' => []];
diff --git a/src/Storage/InMemory/InMemoryActivityLogStorage.php b/src/Storage/InMemory/InMemoryActivityLogStorage.php
new file mode 100644
index 0000000..058893a
--- /dev/null
+++ b/src/Storage/InMemory/InMemoryActivityLogStorage.php
@@ -0,0 +1,110 @@
+>
+ */
+ private array $logs = [];
+
+ private int $nextId = 1;
+
+ public function save(ActivityLogInputInterface $activityLog, string $className): void
+ {
+ $log = new ActivityLog();
+ $log->setId((string) $this->nextId++);
+ $log->setAction($activityLog->getAction());
+ $log->setLoggedAt(clone $activityLog->getLoggedAt());
+ $log->setObjectId($activityLog->getObjectId());
+ $log->setObjectClass($className);
+ $log->setDataChanges($activityLog->getDataChanges());
+ $log->setUser($activityLog->getUser());
+ $log->setRequestUrl($activityLog->getRequestUrl());
+
+ $this->logs[$className][] = $log;
+ }
+
+ /**
+ * @param array $sort
+ *
+ * @return array
+ */
+ public function findByClass(string $className, array $sort = [], int $limit = 20, int $offset = 0): array
+ {
+ return $this->query($className, null, $sort, $limit, $offset);
+ }
+
+ /**
+ * @param array $sort
+ *
+ * @return array
+ */
+ public function findByClassAndObjectId(string $className, mixed $objectId, array $sort = [], int $limit = 20, int $offset = 0): array
+ {
+ return $this->query($className, $objectId, $sort, $limit, $offset);
+ }
+
+ public function clear(string $className): void
+ {
+ unset($this->logs[$className]);
+ }
+
+ /**
+ * @param array $sort
+ *
+ * @return array
+ */
+ private function query(string $className, mixed $objectId, array $sort, int $limit, int $offset): array
+ {
+ $logs = $this->logs[$className] ?? [];
+
+ if (null !== $objectId) {
+ $logs = array_filter($logs, static fn (ActivityLogInterface $log) => $log->getObjectId() === (string) $objectId);
+ }
+
+ $logs = array_values($logs);
+ usort($logs, self::comparator($sort));
+
+ return array_slice($logs, $offset, $limit);
+ }
+
+ /**
+ * @param array $sort
+ */
+ private static function comparator(array $sort): callable
+ {
+ $extractors = [
+ 'loggedAt' => static fn (ActivityLogInterface $log) => $log->getLoggedAt()->getTimestamp(),
+ 'action' => static fn (ActivityLogInterface $log) => $log->getAction(),
+ 'objectId' => static fn (ActivityLogInterface $log) => $log->getObjectId(),
+ ];
+
+ return static function (ActivityLogInterface $a, ActivityLogInterface $b) use ($sort, $extractors): int {
+ foreach ($sort as $field => $direction) {
+ if (!isset($extractors[$field])) {
+ continue;
+ }
+
+ $comparison = $extractors[$field]($a) <=> $extractors[$field]($b);
+
+ if (0 !== $comparison) {
+ return 'desc' === strtolower((string) $direction) ? -$comparison : $comparison;
+ }
+ }
+
+ // deterministic order and tiebreak, insertion order matches ES behavior
+ return (int) $a->getId() <=> (int) $b->getId();
+ };
+ }
+}
diff --git a/src/Storage/InMemory/InMemoryCurrentDataTrackerStorage.php b/src/Storage/InMemory/InMemoryCurrentDataTrackerStorage.php
new file mode 100644
index 0000000..18524f2
--- /dev/null
+++ b/src/Storage/InMemory/InMemoryCurrentDataTrackerStorage.php
@@ -0,0 +1,85 @@
+>
+ */
+ private array $trackers = [];
+
+ private int $nextId = 1;
+
+ public function save(CurrentDataTrackerInputInterface $currentDataTracker, string $className): void
+ {
+ foreach ($this->trackers[$className] ?? [] as $existingTracker) {
+ if ($existingTracker->getObjectId() === (string) $currentDataTracker->getObjectId()) {
+ $this->apply($existingTracker, $currentDataTracker);
+
+ return;
+ }
+ }
+
+ $tracker = new CurrentDataTracker();
+ $tracker->setId((string) $this->nextId++);
+ $tracker->setObjectId($currentDataTracker->getObjectId());
+ $tracker->setObjectClass($className);
+ $this->apply($tracker, $currentDataTracker);
+
+ $this->trackers[$className][] = $tracker;
+ }
+
+ public function update(mixed $id, CurrentDataTrackerInputInterface $currentDataTracker, string $className): void
+ {
+ foreach ($this->trackers[$className] ?? [] as $tracker) {
+ if ((string) $tracker->getId() === (string) $id) {
+ $this->apply($tracker, $currentDataTracker);
+
+ return;
+ }
+ }
+ }
+
+ /**
+ * @param array $currentDataTrackers
+ */
+ public function bulkSave(array $currentDataTrackers, string $className): void
+ {
+ foreach ($currentDataTrackers as $currentDataTracker) {
+ $this->save($currentDataTracker, $className);
+ }
+ }
+
+ public function findByClassAndObjectId(string $className, mixed $objectId): ?CurrentDataTrackerInterface
+ {
+ foreach ($this->trackers[$className] ?? [] as $tracker) {
+ if ($tracker->getObjectId() === (string) $objectId) {
+ return $tracker;
+ }
+ }
+
+ return null;
+ }
+
+ public function clear(string $className): void
+ {
+ unset($this->trackers[$className]);
+ }
+
+ private function apply(CurrentDataTracker $tracker, CurrentDataTrackerInputInterface $input): void
+ {
+ $tracker->setDateTime(clone $input->getDateTime());
+ $tracker->setData($input->getData() ?? '[]');
+ }
+}
diff --git a/src/Storage/InMemory/InMemoryStorageInitializer.php b/src/Storage/InMemory/InMemoryStorageInitializer.php
new file mode 100644
index 0000000..f37cdeb
--- /dev/null
+++ b/src/Storage/InMemory/InMemoryStorageInitializer.php
@@ -0,0 +1,62 @@
+
+ */
+ private array $initializedActivityLogs = [];
+
+ /**
+ * @var array
+ */
+ private array $initializedCurrentDataTrackers = [];
+
+ public function __construct(
+ private readonly InMemoryActivityLogStorage $activityLogStorage,
+ private readonly InMemoryCurrentDataTrackerStorage $currentDataTrackerStorage,
+ ) {
+ }
+
+ public function initializeActivityLogStorage(string $className): bool
+ {
+ if (isset($this->initializedActivityLogs[$className])) {
+ return false;
+ }
+
+ $this->initializedActivityLogs[$className] = true;
+
+ return true;
+ }
+
+ public function initializeCurrentDataTrackerStorage(string $className): bool
+ {
+ if (isset($this->initializedCurrentDataTrackers[$className])) {
+ return false;
+ }
+
+ $this->initializedCurrentDataTrackers[$className] = true;
+
+ return true;
+ }
+
+ public function recreateActivityLogStorage(string $className): void
+ {
+ $this->initializedActivityLogs[$className] = true;
+ $this->activityLogStorage->clear($className);
+ }
+
+ public function recreateCurrentDataTrackerStorage(string $className): void
+ {
+ $this->initializedCurrentDataTrackers[$className] = true;
+ $this->currentDataTrackerStorage->clear($className);
+ }
+}
diff --git a/tests/Fixtures/app/config/packages/framework.yaml b/tests/Fixtures/app/config/packages/framework.yaml
index 12855be..3a5c240 100644
--- a/tests/Fixtures/app/config/packages/framework.yaml
+++ b/tests/Fixtures/app/config/packages/framework.yaml
@@ -17,8 +17,12 @@ framework:
php_errors:
log: true
-when@test:
+when@test: &test_framework
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file
+
+when@in_memory_storage: *test_framework
+
+when@doctrine_storage: *test_framework
diff --git a/tests/Fixtures/app/config/packages/loggastic.yaml b/tests/Fixtures/app/config/packages/loggastic.yaml
index b06eafc..2618d6e 100644
--- a/tests/Fixtures/app/config/packages/loggastic.yaml
+++ b/tests/Fixtures/app/config/packages/loggastic.yaml
@@ -3,3 +3,11 @@ locastic_loggastic:
loggable_paths:
- '%kernel.project_dir%/Resources'
- '%kernel.project_dir%/Model'
+
+when@in_memory_storage:
+ locastic_loggastic:
+ storage: in_memory
+
+when@doctrine_storage:
+ locastic_loggastic:
+ storage: doctrine
diff --git a/tests/FunctionalTests/InMemoryStorageActivityLogTest.php b/tests/FunctionalTests/InMemoryStorageActivityLogTest.php
new file mode 100644
index 0000000..1838c38
--- /dev/null
+++ b/tests/FunctionalTests/InMemoryStorageActivityLogTest.php
@@ -0,0 +1,107 @@
+ 'in_memory_storage']);
+
+ $container = self::getContainer();
+
+ $this->activityLogger = $container->get(ActivityLoggerInterface::class);
+ $this->activityLogProvider = $container->get(ActivityLogProviderInterface::class);
+ }
+
+ protected function tearDown(): void
+ {
+ parent::tearDown();
+
+ $_SERVER['APP_ENV'] = 'test';
+ }
+
+ public function testStorageInterfacesAreAliasedToInMemoryImplementations(): void
+ {
+ self::assertInstanceOf(
+ InMemoryActivityLogStorage::class,
+ self::getContainer()->get(ActivityLogStorageInterface::class)
+ );
+ }
+
+ public function testFullActivityLogFlow(): void
+ {
+ $blogPost = $this->createDummyBlogPost();
+
+ $this->activityLogger->logCreatedItem($blogPost);
+
+ $activityLogs = $this->activityLogProvider->getActivityLogsByClassAndId(DummyBlogPost::class, 15);
+
+ self::assertCount(1, $activityLogs);
+ self::assertEquals(ActivityLogAction::CREATED, $activityLogs[0]->getAction());
+ self::assertEquals(15, $activityLogs[0]->getObjectId());
+
+ // no-op update must not be logged
+ $this->activityLogger->logUpdatedItem($blogPost);
+
+ self::assertCount(1, $this->activityLogProvider->getActivityLogsByClassAndId(DummyBlogPost::class, 15));
+
+ $blogPost->setTitle('Activity logs without Elasticsearch');
+ $this->activityLogger->logUpdatedItem($blogPost);
+
+ $activityLogs = $this->activityLogProvider->getActivityLogsByClassAndId(DummyBlogPost::class, 15);
+
+ self::assertCount(2, $activityLogs);
+ self::assertEquals(ActivityLogAction::EDITED, $activityLogs[1]->getAction());
+ self::assertEquals([
+ 'previousValues' => ['title' => 'Activity logs in memory'],
+ 'currentValues' => ['title' => 'Activity logs without Elasticsearch'],
+ ], $activityLogs[1]->getDataChanges());
+
+ $this->activityLogger->logDeletedItem($blogPost, $blogPost->getId(), DummyBlogPost::class);
+
+ $activityLogs = $this->activityLogProvider->getActivityLogsByClassAndId(DummyBlogPost::class, 15);
+
+ self::assertCount(3, $activityLogs);
+ self::assertEquals(ActivityLogAction::DELETED, $activityLogs[2]->getAction());
+ }
+
+ private function createDummyBlogPost(): DummyBlogPost
+ {
+ $blogPost = new DummyBlogPost();
+
+ $blogPost->setId(15);
+ $blogPost->setEnabled(false);
+ $blogPost->setPosition(2);
+ $blogPost->setPublishAt(new \DateTime('2022-11-11 15:00:00'));
+ $blogPost->setTags(['#php', '#loggastic']);
+ $blogPost->setTitle('Activity logs in memory');
+
+ $photo = new DummyPhoto();
+ $photo->setId(1950);
+ $photo->setPath('path1');
+
+ $blogPost->setPhotos(new ArrayCollection([$photo]));
+
+ return $blogPost;
+ }
+}
diff --git a/tests/IntegrationTest/Bridge/Doctrine/DoctrineStorageTest.php b/tests/IntegrationTest/Bridge/Doctrine/DoctrineStorageTest.php
new file mode 100644
index 0000000..b0820ee
--- /dev/null
+++ b/tests/IntegrationTest/Bridge/Doctrine/DoctrineStorageTest.php
@@ -0,0 +1,241 @@
+connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
+ $this->initializer = new DoctrineStorageInitializer($this->connection);
+ $this->activityLogStorage = new DoctrineActivityLogStorage($this->connection);
+ $this->currentDataTrackerStorage = new DoctrineCurrentDataTrackerStorage($this->connection);
+
+ $this->initializer->initializeActivityLogStorage('Some\Class');
+ $this->initializer->initializeCurrentDataTrackerStorage('Some\Class');
+ }
+
+ public function testInitializeReportsWhetherStorageWasCreated(): void
+ {
+ $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
+ $initializer = new DoctrineStorageInitializer($connection);
+
+ self::assertTrue($initializer->initializeActivityLogStorage('Some\Class'));
+ self::assertFalse($initializer->initializeActivityLogStorage('Some\Class'));
+ self::assertFalse($initializer->initializeActivityLogStorage('Another\Class'));
+
+ self::assertTrue($initializer->initializeCurrentDataTrackerStorage('Some\Class'));
+ self::assertFalse($initializer->initializeCurrentDataTrackerStorage('Some\Class'));
+ }
+
+ public function testActivityLogRoundTrip(): void
+ {
+ $this->activityLogStorage->save($this->createActivityLogInput(
+ '15',
+ 'created',
+ new \DateTime('2026-07-03 10:00:00', new \DateTimeZone('UTC')),
+ '{"currentValues":{"title":"foo"}}',
+ ['username' => 'paula'],
+ 'https://example.com/posts/15'
+ ), 'Some\Class');
+
+ $logs = $this->activityLogStorage->findByClassAndObjectId('Some\Class', 15);
+
+ self::assertCount(1, $logs);
+
+ $log = $logs[0];
+
+ self::assertNotEmpty($log->getId());
+ self::assertSame('created', $log->getAction());
+ self::assertSame('15', $log->getObjectId());
+ self::assertSame('Some\Class', $log->getObjectClass());
+ self::assertSame('2026-07-03 10:00:00', $log->getLoggedAt()->format('Y-m-d H:i:s'));
+ self::assertEquals(['currentValues' => ['title' => 'foo']], $log->getDataChanges());
+ self::assertSame(['username' => 'paula'], $log->getUser());
+ self::assertSame('https://example.com/posts/15', $log->getRequestUrl());
+ }
+
+ public function testActivityLogNullableFieldsRoundTrip(): void
+ {
+ $this->activityLogStorage->save($this->createActivityLogInput('15', 'created', new \DateTime()), 'Some\Class');
+
+ $logs = $this->activityLogStorage->findByClassAndObjectId('Some\Class', 15);
+
+ self::assertCount(1, $logs);
+ self::assertNull($logs[0]->getDataChanges());
+ self::assertNull($logs[0]->getUser());
+ self::assertNull($logs[0]->getRequestUrl());
+ }
+
+ public function testTimestampsAreNormalizedToUtc(): void
+ {
+ $this->activityLogStorage->save($this->createActivityLogInput(
+ '15',
+ 'created',
+ new \DateTime('2026-07-03 12:00:00', new \DateTimeZone('+02:00'))
+ ), 'Some\Class');
+
+ $logs = $this->activityLogStorage->findByClassAndObjectId('Some\Class', 15);
+
+ self::assertSame('2026-07-03 10:00:00', $logs[0]->getLoggedAt()->format('Y-m-d H:i:s'));
+ }
+
+ public function testFindByClassFiltersSortsAndPaginates(): void
+ {
+ foreach ([['15', 'created', '10:00'], ['15', 'edited', '11:00'], ['16', 'created', '12:00'], ['15', 'deleted', '13:00']] as [$objectId, $action, $time]) {
+ $this->activityLogStorage->save($this->createActivityLogInput(
+ $objectId,
+ $action,
+ new \DateTime('2026-07-03 '.$time, new \DateTimeZone('UTC'))
+ ), 'Some\Class');
+ }
+ $this->activityLogStorage->save($this->createActivityLogInput('15', 'created', new \DateTime()), 'Another\Class');
+
+ self::assertCount(4, $this->activityLogStorage->findByClass('Some\Class'));
+ self::assertCount(1, $this->activityLogStorage->findByClass('Another\Class'));
+ self::assertCount(3, $this->activityLogStorage->findByClassAndObjectId('Some\Class', 15));
+
+ $sorted = $this->activityLogStorage->findByClass('Some\Class', ['loggedAt' => 'desc']);
+ self::assertSame(['deleted', 'created', 'edited', 'created'], array_map(static fn ($log) => $log->getAction(), $sorted));
+
+ $page = $this->activityLogStorage->findByClass('Some\Class', ['loggedAt' => 'desc'], 2, 1);
+ self::assertSame(['created', 'edited'], array_map(static fn ($log) => $log->getAction(), $page));
+
+ $unknownSortField = $this->activityLogStorage->findByClass('Some\Class', ['nonexistent' => 'desc']);
+ self::assertCount(4, $unknownSortField);
+ }
+
+ public function testRecreateActivityLogStorageOnlyClearsGivenClass(): void
+ {
+ $this->activityLogStorage->save($this->createActivityLogInput('15', 'created', new \DateTime()), 'Some\Class');
+ $this->activityLogStorage->save($this->createActivityLogInput('15', 'created', new \DateTime()), 'Another\Class');
+
+ $this->initializer->recreateActivityLogStorage('Some\Class');
+
+ self::assertCount(0, $this->activityLogStorage->findByClass('Some\Class'));
+ self::assertCount(1, $this->activityLogStorage->findByClass('Another\Class'));
+ }
+
+ public function testCurrentDataTrackerRoundTrip(): void
+ {
+ $this->currentDataTrackerStorage->save($this->createTrackerInput('15', '{"title":"foo"}'), 'Some\Class');
+
+ self::assertNull($this->currentDataTrackerStorage->findByClassAndObjectId('Some\Class', 16));
+ self::assertNull($this->currentDataTrackerStorage->findByClassAndObjectId('Another\Class', 15));
+
+ $tracker = $this->currentDataTrackerStorage->findByClassAndObjectId('Some\Class', 15);
+
+ self::assertNotNull($tracker);
+ self::assertSame('15', $tracker->getObjectId());
+ self::assertSame('Some\Class', $tracker->getObjectClass());
+ self::assertEquals(['title' => 'foo'], $tracker->getData());
+ }
+
+ public function testCurrentDataTrackerUpdate(): void
+ {
+ $this->currentDataTrackerStorage->save($this->createTrackerInput('15', '{"title":"foo"}'), 'Some\Class');
+
+ $tracker = $this->currentDataTrackerStorage->findByClassAndObjectId('Some\Class', 15);
+ self::assertNotNull($tracker);
+
+ $this->currentDataTrackerStorage->update($tracker->getId(), $this->createTrackerInput('15', '{"title":"bar"}'), 'Some\Class');
+
+ $updated = $this->currentDataTrackerStorage->findByClassAndObjectId('Some\Class', 15);
+ self::assertNotNull($updated);
+ self::assertEquals(['title' => 'bar'], $updated->getData());
+ self::assertSame($tracker->getId(), $updated->getId());
+ }
+
+ public function testCurrentDataTrackerBulkSave(): void
+ {
+ $this->currentDataTrackerStorage->bulkSave([
+ $this->createTrackerInput('15', '{"title":"foo"}'),
+ $this->createTrackerInput('16', '{"title":"bar"}'),
+ ], 'Some\Class');
+
+ self::assertNotNull($this->currentDataTrackerStorage->findByClassAndObjectId('Some\Class', 15));
+ self::assertNotNull($this->currentDataTrackerStorage->findByClassAndObjectId('Some\Class', 16));
+ }
+
+ public function testSavingAnAlreadyTrackedObjectUpdatesTheExistingRow(): void
+ {
+ $this->currentDataTrackerStorage->save($this->createTrackerInput('15', '{"title":"foo"}'), 'Some\Class');
+ $this->currentDataTrackerStorage->save($this->createTrackerInput('15', '{"title":"bar"}'), 'Some\Class');
+
+ $tracker = $this->currentDataTrackerStorage->findByClassAndObjectId('Some\Class', 15);
+
+ self::assertNotNull($tracker);
+ self::assertEquals(['title' => 'bar'], $tracker->getData());
+ self::assertSame(1, $this->countTrackerRows());
+ }
+
+ public function testBulkSaveOverExistingTrackersUpdatesThemInsteadOfFailing(): void
+ {
+ $this->currentDataTrackerStorage->save($this->createTrackerInput('15', '{"title":"foo"}'), 'Some\Class');
+
+ $this->currentDataTrackerStorage->bulkSave([
+ $this->createTrackerInput('15', '{"title":"updated"}'),
+ $this->createTrackerInput('16', '{"title":"new"}'),
+ ], 'Some\Class');
+
+ $existing = $this->currentDataTrackerStorage->findByClassAndObjectId('Some\Class', 15);
+
+ self::assertNotNull($existing);
+ self::assertEquals(['title' => 'updated'], $existing->getData());
+ self::assertNotNull($this->currentDataTrackerStorage->findByClassAndObjectId('Some\Class', 16));
+ self::assertSame(2, $this->countTrackerRows());
+ }
+
+ public function testRecreateCurrentDataTrackerStorageOnlyClearsGivenClass(): void
+ {
+ $this->currentDataTrackerStorage->save($this->createTrackerInput('15', '{}'), 'Some\Class');
+ $this->currentDataTrackerStorage->save($this->createTrackerInput('15', '{}'), 'Another\Class');
+
+ $this->initializer->recreateCurrentDataTrackerStorage('Some\Class');
+
+ self::assertNull($this->currentDataTrackerStorage->findByClassAndObjectId('Some\Class', 15));
+ self::assertNotNull($this->currentDataTrackerStorage->findByClassAndObjectId('Another\Class', 15));
+ }
+
+ private function countTrackerRows(): int
+ {
+ return (int) $this->connection->fetchOne('SELECT COUNT(*) FROM '.DoctrineCurrentDataTrackerStorage::DEFAULT_TABLE);
+ }
+
+ private function createActivityLogInput(string $objectId, string $action, \DateTime $loggedAt, ?string $dataChanges = null, ?array $user = null, ?string $requestUrl = null): ActivityLogInput
+ {
+ $input = new ActivityLogInput();
+ $input->setObjectId($objectId);
+ $input->setAction($action);
+ $input->setLoggedAt($loggedAt);
+ $input->setDataChanges($dataChanges);
+ $input->setUser($user);
+ $input->setRequestUrl($requestUrl);
+
+ return $input;
+ }
+
+ private function createTrackerInput(string $objectId, string $data): CurrentDataTrackerInput
+ {
+ $input = new CurrentDataTrackerInput();
+ $input->setObjectId($objectId);
+ $input->setData($data);
+ $input->setDateTime(new \DateTime('2026-07-03 10:00:00', new \DateTimeZone('UTC')));
+
+ return $input;
+ }
+}
diff --git a/tests/IntegrationTest/Bridge/Doctrine/DoctrineStorageWiringTest.php b/tests/IntegrationTest/Bridge/Doctrine/DoctrineStorageWiringTest.php
new file mode 100644
index 0000000..75c69a5
--- /dev/null
+++ b/tests/IntegrationTest/Bridge/Doctrine/DoctrineStorageWiringTest.php
@@ -0,0 +1,47 @@
+ 'doctrine_storage']);
+ }
+
+ protected function tearDown(): void
+ {
+ parent::tearDown();
+
+ $_SERVER['APP_ENV'] = 'test';
+ }
+
+ public function testStorageInterfacesAreAliasedToDoctrineBridge(): void
+ {
+ $container = self::getContainer();
+
+ self::assertInstanceOf(DoctrineActivityLogStorage::class, $container->get(ActivityLogStorageInterface::class));
+ self::assertInstanceOf(DoctrineCurrentDataTrackerStorage::class, $container->get(CurrentDataTrackerStorageInterface::class));
+ self::assertInstanceOf(DoctrineStorageInitializer::class, $container->get(StorageInitializerInterface::class));
+ }
+
+ public function testElasticsearchServicesAreNotRegistered(): void
+ {
+ self::assertFalse(self::getContainer()->has(ElasticsearchService::class));
+ }
+}
diff --git a/tests/UnitTests/Storage/InMemory/InMemoryStorageTest.php b/tests/UnitTests/Storage/InMemory/InMemoryStorageTest.php
new file mode 100644
index 0000000..12fd3ad
--- /dev/null
+++ b/tests/UnitTests/Storage/InMemory/InMemoryStorageTest.php
@@ -0,0 +1,187 @@
+activityLogStorage = new InMemoryActivityLogStorage();
+ $this->currentDataTrackerStorage = new InMemoryCurrentDataTrackerStorage();
+ $this->initializer = new InMemoryStorageInitializer($this->activityLogStorage, $this->currentDataTrackerStorage);
+ }
+
+ public function testInitializeReportsWhetherStorageWasCreated(): void
+ {
+ self::assertTrue($this->initializer->initializeActivityLogStorage('Some\Class'));
+ self::assertFalse($this->initializer->initializeActivityLogStorage('Some\Class'));
+
+ self::assertTrue($this->initializer->initializeCurrentDataTrackerStorage('Some\Class'));
+ self::assertFalse($this->initializer->initializeCurrentDataTrackerStorage('Some\Class'));
+ }
+
+ public function testActivityLogRoundTrip(): void
+ {
+ $this->activityLogStorage->save($this->createActivityLogInput(
+ '15',
+ 'created',
+ new \DateTime('2026-07-03 10:00:00'),
+ '{"currentValues":{"title":"foo"}}',
+ ['username' => 'paula'],
+ 'https://example.com/posts/15'
+ ), 'Some\Class');
+
+ $logs = $this->activityLogStorage->findByClassAndObjectId('Some\Class', 15);
+
+ self::assertCount(1, $logs);
+
+ $log = $logs[0];
+
+ self::assertNotEmpty($log->getId());
+ self::assertSame('created', $log->getAction());
+ self::assertSame('15', $log->getObjectId());
+ self::assertSame('Some\Class', $log->getObjectClass());
+ self::assertEquals(['currentValues' => ['title' => 'foo']], $log->getDataChanges());
+ self::assertSame(['username' => 'paula'], $log->getUser());
+ self::assertSame('https://example.com/posts/15', $log->getRequestUrl());
+ }
+
+ public function testFindByClassFiltersSortsAndPaginates(): void
+ {
+ foreach ([['15', 'created', '10:00'], ['15', 'edited', '11:00'], ['16', 'created', '12:00'], ['15', 'deleted', '13:00']] as [$objectId, $action, $time]) {
+ $this->activityLogStorage->save($this->createActivityLogInput($objectId, $action, new \DateTime('2026-07-03 '.$time)), 'Some\Class');
+ }
+ $this->activityLogStorage->save($this->createActivityLogInput('15', 'created', new \DateTime()), 'Another\Class');
+
+ self::assertCount(4, $this->activityLogStorage->findByClass('Some\Class'));
+ self::assertCount(1, $this->activityLogStorage->findByClass('Another\Class'));
+ self::assertCount(3, $this->activityLogStorage->findByClassAndObjectId('Some\Class', 15));
+
+ $sorted = $this->activityLogStorage->findByClass('Some\Class', ['loggedAt' => 'desc']);
+ self::assertSame(['deleted', 'created', 'edited', 'created'], array_map(static fn ($log) => $log->getAction(), $sorted));
+
+ $page = $this->activityLogStorage->findByClass('Some\Class', ['loggedAt' => 'desc'], 2, 1);
+ self::assertSame(['created', 'edited'], array_map(static fn ($log) => $log->getAction(), $page));
+
+ $unknownSortField = $this->activityLogStorage->findByClass('Some\Class', ['nonexistent' => 'desc']);
+ self::assertCount(4, $unknownSortField);
+ }
+
+ public function testDefaultOrderIsInsertionOrder(): void
+ {
+ $sameInstant = new \DateTime('2026-07-03 10:00:00');
+
+ foreach (['created', 'edited', 'deleted'] as $action) {
+ $this->activityLogStorage->save($this->createActivityLogInput('15', $action, $sameInstant), 'Some\Class');
+ }
+
+ $logs = $this->activityLogStorage->findByClass('Some\Class', ['loggedAt' => 'asc']);
+
+ self::assertSame(['created', 'edited', 'deleted'], array_map(static fn ($log) => $log->getAction(), $logs));
+ }
+
+ public function testRecreateActivityLogStorageOnlyClearsGivenClass(): void
+ {
+ $this->activityLogStorage->save($this->createActivityLogInput('15', 'created', new \DateTime()), 'Some\Class');
+ $this->activityLogStorage->save($this->createActivityLogInput('15', 'created', new \DateTime()), 'Another\Class');
+
+ $this->initializer->recreateActivityLogStorage('Some\Class');
+
+ self::assertCount(0, $this->activityLogStorage->findByClass('Some\Class'));
+ self::assertCount(1, $this->activityLogStorage->findByClass('Another\Class'));
+ }
+
+ public function testCurrentDataTrackerRoundTripAndUpdate(): void
+ {
+ $this->currentDataTrackerStorage->save($this->createTrackerInput('15', '{"title":"foo"}'), 'Some\Class');
+
+ self::assertNull($this->currentDataTrackerStorage->findByClassAndObjectId('Some\Class', 16));
+ self::assertNull($this->currentDataTrackerStorage->findByClassAndObjectId('Another\Class', 15));
+
+ $tracker = $this->currentDataTrackerStorage->findByClassAndObjectId('Some\Class', 15);
+
+ self::assertNotNull($tracker);
+ self::assertSame('15', $tracker->getObjectId());
+ self::assertSame('Some\Class', $tracker->getObjectClass());
+ self::assertEquals(['title' => 'foo'], $tracker->getData());
+
+ $this->currentDataTrackerStorage->update($tracker->getId(), $this->createTrackerInput('15', '{"title":"bar"}'), 'Some\Class');
+
+ $updated = $this->currentDataTrackerStorage->findByClassAndObjectId('Some\Class', 15);
+ self::assertNotNull($updated);
+ self::assertEquals(['title' => 'bar'], $updated->getData());
+ self::assertSame($tracker->getId(), $updated->getId());
+ }
+
+ public function testCurrentDataTrackerBulkSave(): void
+ {
+ $this->currentDataTrackerStorage->bulkSave([
+ $this->createTrackerInput('15', '{"title":"foo"}'),
+ $this->createTrackerInput('16', '{"title":"bar"}'),
+ ], 'Some\Class');
+
+ self::assertNotNull($this->currentDataTrackerStorage->findByClassAndObjectId('Some\Class', 15));
+ self::assertNotNull($this->currentDataTrackerStorage->findByClassAndObjectId('Some\Class', 16));
+ }
+
+ public function testSavingAnAlreadyTrackedObjectUpdatesTheExistingTracker(): void
+ {
+ $this->currentDataTrackerStorage->save($this->createTrackerInput('15', '{"title":"foo"}'), 'Some\Class');
+
+ $original = $this->currentDataTrackerStorage->findByClassAndObjectId('Some\Class', 15);
+ self::assertNotNull($original);
+
+ $this->currentDataTrackerStorage->save($this->createTrackerInput('15', '{"title":"bar"}'), 'Some\Class');
+
+ $tracker = $this->currentDataTrackerStorage->findByClassAndObjectId('Some\Class', 15);
+
+ self::assertNotNull($tracker);
+ self::assertEquals(['title' => 'bar'], $tracker->getData());
+ self::assertSame($original->getId(), $tracker->getId());
+ }
+
+ public function testRecreateCurrentDataTrackerStorageOnlyClearsGivenClass(): void
+ {
+ $this->currentDataTrackerStorage->save($this->createTrackerInput('15', '{}'), 'Some\Class');
+ $this->currentDataTrackerStorage->save($this->createTrackerInput('15', '{}'), 'Another\Class');
+
+ $this->initializer->recreateCurrentDataTrackerStorage('Some\Class');
+
+ self::assertNull($this->currentDataTrackerStorage->findByClassAndObjectId('Some\Class', 15));
+ self::assertNotNull($this->currentDataTrackerStorage->findByClassAndObjectId('Another\Class', 15));
+ }
+
+ private function createActivityLogInput(string $objectId, string $action, \DateTime $loggedAt, ?string $dataChanges = null, ?array $user = null, ?string $requestUrl = null): ActivityLogInput
+ {
+ $input = new ActivityLogInput();
+ $input->setObjectId($objectId);
+ $input->setAction($action);
+ $input->setLoggedAt($loggedAt);
+ $input->setDataChanges($dataChanges);
+ $input->setUser($user);
+ $input->setRequestUrl($requestUrl);
+
+ return $input;
+ }
+
+ private function createTrackerInput(string $objectId, string $data): CurrentDataTrackerInput
+ {
+ $input = new CurrentDataTrackerInput();
+ $input->setObjectId($objectId);
+ $input->setData($data);
+ $input->setDateTime(new \DateTime('2026-07-03 10:00:00'));
+
+ return $input;
+ }
+}