diff --git a/.gitignore b/.gitignore
index ab27d1e..2bd04bf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
/.idea
-/vendor
\ No newline at end of file
+/vendor
+/.phpunit.cache
diff --git a/README.md b/README.md
index 35e8c9d..6c79c26 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,8 @@
# DoctrineLoggable
+Logging of changes in Doctrine entities. Every change is stored as one `change_log` row with the
+change set serialized to JSON, so the log is readable straight from the database.
+
## Installation
1. Install via composer:
@@ -7,61 +10,173 @@
```bash
composer require adt/doctrine-loggable
```
-
+
2. Register this extension in your config.neon:
```neon
extensions:
- ADT\DoctrineLoggable\DI\LoggableExtension
```
-
+
3. Do database migrations
-4. Add annotation to entities you wish to log
+4. Add attributes to entities you wish to log
```php
$value->getAmount(), 'currency' => $value->getCurrency()];
+ }
+ public function decode(array $data, ValueSerializer $serializer): Money
+ {
+ return new Money($data['amount'], $data['currency']);
+ }
}
```
+
+```neon
+doctrineLoggable:
+ valueHandlers:
+ - App\Log\MoneyHandler
+```
+
+Handlers registered this way are tried before the built-in ones, so they can override them.
+
+## Upgrading from 3.x
+
+The `change_set` column changes from `LONGBLOB` holding a PHP `serialize()` payload to a JSON
+column. Convert the existing rows first, while the column is still a BLOB:
+
+```bash
+php bin/console doctrine-loggable:convert-legacy-change-sets --dry-run
+php bin/console doctrine-loggable:convert-legacy-change-sets
+```
+
+The command reads and rewrites the rows with plain SQL, so the `change_set` DBAL type never sees a
+legacy payload. It commits per batch (`--batch-size`, 500 by default), recognises rows that already
+hold JSON and leaves them alone, so it is safe to run again. A row that fails to convert is
+reported and left untouched, and the command exits with a failure so you do not change the column
+type on top of data that did not make it.
+
+Only when it reports no failures:
+
+```sql
+ALTER TABLE change_log MODIFY change_set JSON NOT NULL;
+```
+
+`symfony/console` is optional. Without it the command is not registered and you can drive
+`ADT\DoctrineLoggable\Service\LegacyChangeSetConverter` yourself.
+
+`Adt\DoctrineLoggable\ChangeSet\*` is now declared as `ADT\DoctrineLoggable\ChangeSet\*`, which is
+what the PSR-4 prefix always said. The old casing keeps autoloading, PHP treats both as the same
+class.
+
+## Tests
+
+```bash
+composer install
+vendor/bin/phpunit
+```
diff --git a/composer.json b/composer.json
index 24a7900..7a790d4 100644
--- a/composer.json
+++ b/composer.json
@@ -26,8 +26,25 @@
},
"autoload": {
"psr-4": {
- "ADT\\DoctrineLoggable\\": "src/"
+ "ADT\\DoctrineLoggable\\": "src/",
+ "Adt\\DoctrineLoggable\\": "src/"
},
- "classmap": ["src/exceptions.php"]
+ "classmap": [
+ "src/exceptions.php"
+ ]
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.5 | ^12.0 | ^13.0",
+ "symfony/cache": "^6.0 | ^7.0",
+ "symfony/var-exporter": "^6.4 | ^7.0",
+ "symfony/console": "^6.0 | ^7.0"
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "ADT\\DoctrineLoggable\\Tests\\": "tests/"
+ }
+ },
+ "suggest": {
+ "symfony/console": "To run doctrine-loggable:convert-legacy-change-sets, the migration of change sets stored by version 3"
}
}
diff --git a/composer.lock b/composer.lock
index 890a614..af8d229 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,33 +4,33 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "dd6d6858dddab6c721da08534c86caf1",
+ "content-hash": "c9b62bf1ea02d8dc6f4809813b3f96b2",
"packages": [
{
"name": "doctrine/collections",
- "version": "2.2.2",
+ "version": "2.6.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/collections.git",
- "reference": "d8af7f248c74f195f7347424600fd9e17b57af59"
+ "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/collections/zipball/d8af7f248c74f195f7347424600fd9e17b57af59",
- "reference": "d8af7f248c74f195f7347424600fd9e17b57af59",
+ "url": "https://api.github.com/repos/doctrine/collections/zipball/7713da39d8e237f28411d6a616a3dce5e20d5de2",
+ "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2",
"shasum": ""
},
"require": {
"doctrine/deprecations": "^1",
- "php": "^8.1"
+ "php": "^8.1",
+ "symfony/polyfill-php84": "^1.30"
},
"require-dev": {
- "doctrine/coding-standard": "^12",
+ "doctrine/coding-standard": "^14",
"ext-json": "*",
- "phpstan/phpstan": "^1.8",
- "phpstan/phpstan-phpunit": "^1.0",
- "phpunit/phpunit": "^10.5",
- "vimeo/psalm": "^5.11"
+ "phpstan/phpstan": "^2.1.30",
+ "phpstan/phpstan-phpunit": "^2.0.7",
+ "phpunit/phpunit": "^10.5.58 || ^11.5.42 || ^12.4"
},
"type": "library",
"autoload": {
@@ -74,7 +74,7 @@
],
"support": {
"issues": "https://github.com/doctrine/collections/issues",
- "source": "https://github.com/doctrine/collections/tree/2.2.2"
+ "source": "https://github.com/doctrine/collections/tree/2.6.0"
},
"funding": [
{
@@ -90,7 +90,7 @@
"type": "tidelift"
}
],
- "time": "2024-04-18T06:56:21+00:00"
+ "time": "2026-01-15T10:01:58+00:00"
},
{
"name": "doctrine/common",
@@ -185,36 +185,36 @@
},
{
"name": "doctrine/dbal",
- "version": "4.2.2",
+ "version": "4.4.4",
"source": {
"type": "git",
"url": "https://github.com/doctrine/dbal.git",
- "reference": "19a2b7deb5fe8c2df0ff817ecea305e50acb62ec"
+ "reference": "fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/dbal/zipball/19a2b7deb5fe8c2df0ff817ecea305e50acb62ec",
- "reference": "19a2b7deb5fe8c2df0ff817ecea305e50acb62ec",
+ "url": "https://api.github.com/repos/doctrine/dbal/zipball/fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce",
+ "reference": "fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce",
"shasum": ""
},
"require": {
- "doctrine/deprecations": "^0.5.3|^1",
- "php": "^8.1",
+ "doctrine/deprecations": "^1.1.5",
+ "php": "^8.2",
"psr/cache": "^1|^2|^3",
"psr/log": "^1|^2|^3"
},
"require-dev": {
- "doctrine/coding-standard": "12.0.0",
+ "doctrine/coding-standard": "14.0.0",
"fig/log-test": "^1",
"jetbrains/phpstorm-stubs": "2023.2",
- "phpstan/phpstan": "2.1.1",
- "phpstan/phpstan-phpunit": "2.0.3",
+ "phpstan/phpstan": "2.1.30",
+ "phpstan/phpstan-phpunit": "2.0.7",
"phpstan/phpstan-strict-rules": "^2",
- "phpunit/phpunit": "10.5.39",
- "slevomat/coding-standard": "8.13.1",
- "squizlabs/php_codesniffer": "3.10.2",
- "symfony/cache": "^6.3.8|^7.0",
- "symfony/console": "^5.4|^6.3|^7.0"
+ "phpunit/phpunit": "11.5.50",
+ "slevomat/coding-standard": "8.27.1",
+ "squizlabs/php_codesniffer": "4.0.1",
+ "symfony/cache": "^6.3.8|^7.0|^8.0",
+ "symfony/console": "^5.4|^6.3|^7.0|^8.0"
},
"suggest": {
"symfony/console": "For helpful console commands such as SQL execution and import of files."
@@ -271,7 +271,7 @@
],
"support": {
"issues": "https://github.com/doctrine/dbal/issues",
- "source": "https://github.com/doctrine/dbal/tree/4.2.2"
+ "source": "https://github.com/doctrine/dbal/tree/4.4.4"
},
"funding": [
{
@@ -287,30 +287,33 @@
"type": "tidelift"
}
],
- "time": "2025-01-16T08:40:56+00:00"
+ "time": "2026-07-21T14:34:40+00:00"
},
{
"name": "doctrine/deprecations",
- "version": "1.1.4",
+ "version": "1.1.6",
"source": {
"type": "git",
"url": "https://github.com/doctrine/deprecations.git",
- "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9"
+ "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/deprecations/zipball/31610dbb31faa98e6b5447b62340826f54fbc4e9",
- "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9",
+ "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
+ "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
+ "conflict": {
+ "phpunit/phpunit": "<=7.5 || >=14"
+ },
"require-dev": {
- "doctrine/coding-standard": "^9 || ^12",
- "phpstan/phpstan": "1.4.10 || 2.0.3",
+ "doctrine/coding-standard": "^9 || ^12 || ^14",
+ "phpstan/phpstan": "1.4.10 || 2.1.30",
"phpstan/phpstan-phpunit": "^1.0 || ^2",
- "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
"psr/log": "^1 || ^2 || ^3"
},
"suggest": {
@@ -330,22 +333,22 @@
"homepage": "https://www.doctrine-project.org/",
"support": {
"issues": "https://github.com/doctrine/deprecations/issues",
- "source": "https://github.com/doctrine/deprecations/tree/1.1.4"
+ "source": "https://github.com/doctrine/deprecations/tree/1.1.6"
},
- "time": "2024-12-07T21:18:45+00:00"
+ "time": "2026-02-07T07:09:04+00:00"
},
{
"name": "doctrine/event-manager",
- "version": "2.0.1",
+ "version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/doctrine/event-manager.git",
- "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e"
+ "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/event-manager/zipball/b680156fa328f1dfd874fd48c7026c41570b9c6e",
- "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e",
+ "url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf",
+ "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf",
"shasum": ""
},
"require": {
@@ -355,10 +358,10 @@
"doctrine/common": "<2.9"
},
"require-dev": {
- "doctrine/coding-standard": "^12",
- "phpstan/phpstan": "^1.8.8",
- "phpunit/phpunit": "^10.5",
- "vimeo/psalm": "^5.24"
+ "doctrine/coding-standard": "^14",
+ "phpdocumentor/guides-cli": "^1.4",
+ "phpstan/phpstan": "^2.1.32",
+ "phpunit/phpunit": "^10.5.58"
},
"type": "library",
"autoload": {
@@ -407,7 +410,7 @@
],
"support": {
"issues": "https://github.com/doctrine/event-manager/issues",
- "source": "https://github.com/doctrine/event-manager/tree/2.0.1"
+ "source": "https://github.com/doctrine/event-manager/tree/2.1.1"
},
"funding": [
{
@@ -423,37 +426,36 @@
"type": "tidelift"
}
],
- "time": "2024-05-22T20:47:39+00:00"
+ "time": "2026-01-29T07:11:08+00:00"
},
{
"name": "doctrine/inflector",
- "version": "2.0.10",
+ "version": "2.1.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/inflector.git",
- "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc"
+ "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc",
- "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc",
+ "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b",
+ "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
- "doctrine/coding-standard": "^11.0",
- "phpstan/phpstan": "^1.8",
- "phpstan/phpstan-phpunit": "^1.1",
- "phpstan/phpstan-strict-rules": "^1.3",
- "phpunit/phpunit": "^8.5 || ^9.5",
- "vimeo/psalm": "^4.25 || ^5.4"
+ "doctrine/coding-standard": "^12.0 || ^13.0",
+ "phpstan/phpstan": "^1.12 || ^2.0",
+ "phpstan/phpstan-phpunit": "^1.4 || ^2.0",
+ "phpstan/phpstan-strict-rules": "^1.6 || ^2.0",
+ "phpunit/phpunit": "^8.5 || ^12.2"
},
"type": "library",
"autoload": {
"psr-4": {
- "Doctrine\\Inflector\\": "lib/Doctrine/Inflector"
+ "Doctrine\\Inflector\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -498,7 +500,7 @@
],
"support": {
"issues": "https://github.com/doctrine/inflector/issues",
- "source": "https://github.com/doctrine/inflector/tree/2.0.10"
+ "source": "https://github.com/doctrine/inflector/tree/2.1.0"
},
"funding": [
{
@@ -514,34 +516,33 @@
"type": "tidelift"
}
],
- "time": "2024-02-18T20:23:39+00:00"
+ "time": "2025-08-10T19:31:58+00:00"
},
{
"name": "doctrine/instantiator",
- "version": "2.0.0",
+ "version": "2.1.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/instantiator.git",
- "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0"
+ "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0",
- "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0",
+ "url": "https://api.github.com/repos/doctrine/instantiator/zipball/23da848e1a2308728fe5fdddabf4be17ff9720c7",
+ "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7",
"shasum": ""
},
"require": {
- "php": "^8.1"
+ "php": "^8.4"
},
"require-dev": {
- "doctrine/coding-standard": "^11",
+ "doctrine/coding-standard": "^14",
"ext-pdo": "*",
"ext-phar": "*",
"phpbench/phpbench": "^1.2",
- "phpstan/phpstan": "^1.9.4",
- "phpstan/phpstan-phpunit": "^1.3",
- "phpunit/phpunit": "^9.5.27",
- "vimeo/psalm": "^5.4"
+ "phpstan/phpstan": "^2.1",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpunit/phpunit": "^10.5.58"
},
"type": "library",
"autoload": {
@@ -568,7 +569,7 @@
],
"support": {
"issues": "https://github.com/doctrine/instantiator/issues",
- "source": "https://github.com/doctrine/instantiator/tree/2.0.0"
+ "source": "https://github.com/doctrine/instantiator/tree/2.1.0"
},
"funding": [
{
@@ -584,7 +585,7 @@
"type": "tidelift"
}
],
- "time": "2022-12-30T00:23:10+00:00"
+ "time": "2026-01-05T06:47:08+00:00"
},
{
"name": "doctrine/lexer",
@@ -665,16 +666,16 @@
},
{
"name": "doctrine/orm",
- "version": "3.3.1",
+ "version": "3.6.8",
"source": {
"type": "git",
"url": "https://github.com/doctrine/orm.git",
- "reference": "b1f8253105aa5382c495e5f9f8ef34e297775428"
+ "reference": "a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/orm/zipball/b1f8253105aa5382c495e5f9f8ef34e297775428",
- "reference": "b1f8253105aa5382c495e5f9f8ef34e297775428",
+ "url": "https://api.github.com/repos/doctrine/orm/zipball/a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc",
+ "reference": "a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc",
"shasum": ""
},
"require": {
@@ -690,22 +691,21 @@
"ext-ctype": "*",
"php": "^8.1",
"psr/cache": "^1 || ^2 || ^3",
- "symfony/console": "^5.4 || ^6.0 || ^7.0",
- "symfony/var-exporter": "^6.3.9 || ^7.0"
+ "symfony/console": "^5.4 || ^6.0 || ^7.0 || ^8.0",
+ "symfony/var-exporter": "^6.3.9 || ^7.0 || ^8.0"
},
"require-dev": {
- "doctrine/coding-standard": "^12.0",
+ "doctrine/coding-standard": "^14.0",
"phpbench/phpbench": "^1.0",
- "phpdocumentor/guides-cli": "^1.4",
"phpstan/extension-installer": "^1.4",
- "phpstan/phpstan": "2.0.3",
+ "phpstan/phpstan": "2.1.23",
"phpstan/phpstan-deprecation-rules": "^2",
- "phpunit/phpunit": "^10.4.0",
+ "phpunit/phpunit": "^10.5.0 || ^11.5",
"psr/log": "^1 || ^2 || ^3",
- "squizlabs/php_codesniffer": "3.7.2",
- "symfony/cache": "^5.4 || ^6.2 || ^7.0"
+ "symfony/cache": "^5.4 || ^6.2 || ^7.0 || ^8.0"
},
"suggest": {
+ "ext-deepclone": "Improves performance when not using native lazy objects (Symfony 8.1+)",
"ext-dom": "Provides support for XSD validation for XML mapping files",
"symfony/cache": "Provides cache support for Setup Tool with doctrine/cache 2.0"
},
@@ -749,44 +749,43 @@
],
"support": {
"issues": "https://github.com/doctrine/orm/issues",
- "source": "https://github.com/doctrine/orm/tree/3.3.1"
+ "source": "https://github.com/doctrine/orm/tree/3.6.8"
},
- "time": "2024-12-19T07:08:14+00:00"
+ "time": "2026-08-05T19:05:32+00:00"
},
{
"name": "doctrine/persistence",
- "version": "4.0.0",
+ "version": "4.2.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/persistence.git",
- "reference": "45004aca79189474f113cbe3a53847c2115a55fa"
+ "reference": "49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/persistence/zipball/45004aca79189474f113cbe3a53847c2115a55fa",
- "reference": "45004aca79189474f113cbe3a53847c2115a55fa",
+ "url": "https://api.github.com/repos/doctrine/persistence/zipball/49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b",
+ "reference": "49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b",
"shasum": ""
},
"require": {
+ "doctrine/deprecations": "^1",
"doctrine/event-manager": "^1 || ^2",
"php": "^8.1",
"psr/cache": "^1.0 || ^2.0 || ^3.0"
},
- "conflict": {
- "doctrine/common": "<2.10"
- },
"require-dev": {
- "doctrine/coding-standard": "^12",
- "phpstan/phpstan": "1.12.7",
- "phpstan/phpstan-phpunit": "^1",
- "phpstan/phpstan-strict-rules": "^1.1",
- "phpunit/phpunit": "^9.6",
- "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0"
+ "doctrine/coding-standard": "^14",
+ "phpstan/phpstan": "2.1.30",
+ "phpstan/phpstan-phpunit": "^2",
+ "phpstan/phpstan-strict-rules": "^2",
+ "phpunit/phpunit": "^10.5.58 || ^12",
+ "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0 || ^8.0",
+ "symfony/finder": "^4.4 || ^5.4 || ^6.0 || ^7.0 || ^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
- "Doctrine\\Persistence\\": "src/Persistence"
+ "Doctrine\\Persistence\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -830,7 +829,7 @@
],
"support": {
"issues": "https://github.com/doctrine/persistence/issues",
- "source": "https://github.com/doctrine/persistence/tree/4.0.0"
+ "source": "https://github.com/doctrine/persistence/tree/4.2.0"
},
"funding": [
{
@@ -846,35 +845,38 @@
"type": "tidelift"
}
],
- "time": "2024-11-01T21:49:07+00:00"
+ "time": "2026-04-26T12:12:52+00:00"
},
{
"name": "nette/di",
- "version": "v3.2.4",
+ "version": "v3.2.7",
"source": {
"type": "git",
"url": "https://github.com/nette/di.git",
- "reference": "57f923a7af32435b6e4921c0adbc70c619625a17"
+ "reference": "7b00ce16011f628e5861a81a2dbf74edcd4e97cd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nette/di/zipball/57f923a7af32435b6e4921c0adbc70c619625a17",
- "reference": "57f923a7af32435b6e4921c0adbc70c619625a17",
+ "url": "https://api.github.com/repos/nette/di/zipball/7b00ce16011f628e5861a81a2dbf74edcd4e97cd",
+ "reference": "7b00ce16011f628e5861a81a2dbf74edcd4e97cd",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-tokenizer": "*",
- "nette/neon": "^3.3 || ^4.0",
+ "nette/neon": "^3.4",
"nette/php-generator": "^4.1.6",
"nette/robot-loader": "^4.0",
"nette/schema": "^1.2.5",
- "nette/utils": "^4.0",
- "php": "8.1 - 8.4"
+ "nette/utils": "^4.0.6",
+ "php": "8.1 - 8.5"
},
"require-dev": {
- "nette/tester": "^2.5.2",
- "phpstan/phpstan": "^1.0",
+ "nette/phpstan-rules": "^1.0",
+ "nette/tester": "^2.6",
+ "phpstan/extension-installer": "^1.4@stable",
+ "phpstan/phpstan": "^2.1@stable",
+ "psr/container": "^1.1 || ^2.0",
"tracy/tracy": "^2.9"
},
"type": "library",
@@ -884,6 +886,9 @@
}
},
"autoload": {
+ "psr-4": {
+ "Nette\\": "src"
+ },
"classmap": [
"src/"
]
@@ -917,31 +922,31 @@
],
"support": {
"issues": "https://github.com/nette/di/issues",
- "source": "https://github.com/nette/di/tree/v3.2.4"
+ "source": "https://github.com/nette/di/tree/v3.2.7"
},
- "time": "2025-01-10T04:57:37+00:00"
+ "time": "2026-08-27T01:12:29+00:00"
},
{
"name": "nette/neon",
- "version": "v3.4.4",
+ "version": "v3.4.8",
"source": {
"type": "git",
"url": "https://github.com/nette/neon.git",
- "reference": "3411aa86b104e2d5b7e760da4600865ead963c3c"
+ "reference": "9009f99ce1396b366a88ca16c90177148352b7d3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nette/neon/zipball/3411aa86b104e2d5b7e760da4600865ead963c3c",
- "reference": "3411aa86b104e2d5b7e760da4600865ead963c3c",
+ "url": "https://api.github.com/repos/nette/neon/zipball/9009f99ce1396b366a88ca16c90177148352b7d3",
+ "reference": "9009f99ce1396b366a88ca16c90177148352b7d3",
"shasum": ""
},
"require": {
"ext-json": "*",
- "php": "8.0 - 8.4"
+ "php": "8.0 - 8.5"
},
"require-dev": {
"nette/tester": "^2.4",
- "phpstan/phpstan": "^1.0",
+ "phpstan/phpstan-nette": "^2.0@stable",
"tracy/tracy": "^2.7"
},
"bin": [
@@ -954,6 +959,9 @@
}
},
"autoload": {
+ "psr-4": {
+ "Nette\\": "src"
+ },
"classmap": [
"src/"
]
@@ -975,7 +983,7 @@
}
],
"description": "🍸 Nette NEON: encodes and decodes NEON file format.",
- "homepage": "https://ne-on.org",
+ "homepage": "https://neon.nette.org",
"keywords": [
"export",
"import",
@@ -985,33 +993,35 @@
],
"support": {
"issues": "https://github.com/nette/neon/issues",
- "source": "https://github.com/nette/neon/tree/v3.4.4"
+ "source": "https://github.com/nette/neon/tree/v3.4.8"
},
- "time": "2024-10-04T22:00:08+00:00"
+ "time": "2026-05-11T21:34:00+00:00"
},
{
"name": "nette/php-generator",
- "version": "v4.1.7",
+ "version": "v4.2.2",
"source": {
"type": "git",
"url": "https://github.com/nette/php-generator.git",
- "reference": "d201c9bc217e0969d1b678d286be49302972fb56"
+ "reference": "0d7060926f5c3e8c488b9b9ced42d857f12a34b5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nette/php-generator/zipball/d201c9bc217e0969d1b678d286be49302972fb56",
- "reference": "d201c9bc217e0969d1b678d286be49302972fb56",
+ "url": "https://api.github.com/repos/nette/php-generator/zipball/0d7060926f5c3e8c488b9b9ced42d857f12a34b5",
+ "reference": "0d7060926f5c3e8c488b9b9ced42d857f12a34b5",
"shasum": ""
},
"require": {
- "nette/utils": "^3.2.9 || ^4.0",
- "php": "8.0 - 8.4"
+ "nette/utils": "^4.0.6",
+ "php": "8.1 - 8.5"
},
"require-dev": {
- "jetbrains/phpstorm-attributes": "dev-master",
- "nette/tester": "^2.4",
- "nikic/php-parser": "^4.18 || ^5.0",
- "phpstan/phpstan": "^1.0",
+ "jetbrains/phpstorm-attributes": "^1.2",
+ "nette/phpstan-rules": "^1.0",
+ "nette/tester": "^2.6",
+ "nikic/php-parser": "^5.0",
+ "phpstan/extension-installer": "^1.4@stable",
+ "phpstan/phpstan": "^2.1.40@stable",
"tracy/tracy": "^2.8"
},
"suggest": {
@@ -1020,10 +1030,13 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "4.1-dev"
+ "dev-master": "4.2-dev"
}
},
"autoload": {
+ "psr-4": {
+ "Nette\\": "src"
+ },
"classmap": [
"src/"
]
@@ -1044,7 +1057,7 @@
"homepage": "https://nette.org/contributors"
}
],
- "description": "🐘 Nette PHP Generator: generates neat PHP code for you. Supports new PHP 8.4 features.",
+ "description": "🐘 Nette PHP Generator: generates neat PHP code for you. Supports new PHP 8.5 features.",
"homepage": "https://nette.org",
"keywords": [
"code",
@@ -1054,41 +1067,46 @@
],
"support": {
"issues": "https://github.com/nette/php-generator/issues",
- "source": "https://github.com/nette/php-generator/tree/v4.1.7"
+ "source": "https://github.com/nette/php-generator/tree/v4.2.2"
},
- "time": "2024-11-29T01:41:18+00:00"
+ "time": "2026-02-26T00:58:33+00:00"
},
{
"name": "nette/robot-loader",
- "version": "v4.0.3",
+ "version": "v4.1.3",
"source": {
"type": "git",
"url": "https://github.com/nette/robot-loader.git",
- "reference": "45d67753fb4865bb718e9a6c9be69cc9470137b7"
+ "reference": "44dba0bbb9cb521a3fed15046d3294c85dabe516"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nette/robot-loader/zipball/45d67753fb4865bb718e9a6c9be69cc9470137b7",
- "reference": "45d67753fb4865bb718e9a6c9be69cc9470137b7",
+ "url": "https://api.github.com/repos/nette/robot-loader/zipball/44dba0bbb9cb521a3fed15046d3294c85dabe516",
+ "reference": "44dba0bbb9cb521a3fed15046d3294c85dabe516",
"shasum": ""
},
"require": {
"ext-tokenizer": "*",
- "nette/utils": "^4.0",
- "php": "8.0 - 8.4"
+ "nette/utils": "^4.0.6",
+ "php": "8.1 - 8.5"
},
"require-dev": {
- "nette/tester": "^2.4",
- "phpstan/phpstan": "^1.0",
+ "nette/phpstan-rules": "^1.0",
+ "nette/tester": "^2.6",
+ "phpstan/extension-installer": "^1.4@stable",
+ "phpstan/phpstan": "^2.1.39@stable",
"tracy/tracy": "^2.9"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "4.0-dev"
+ "dev-master": "4.1-dev"
}
},
"autoload": {
+ "psr-4": {
+ "Nette\\": "src"
+ },
"classmap": [
"src/"
]
@@ -1120,31 +1138,33 @@
],
"support": {
"issues": "https://github.com/nette/robot-loader/issues",
- "source": "https://github.com/nette/robot-loader/tree/v4.0.3"
+ "source": "https://github.com/nette/robot-loader/tree/v4.1.3"
},
- "time": "2024-06-18T20:26:39+00:00"
+ "time": "2026-08-18T10:23:46+00:00"
},
{
"name": "nette/schema",
- "version": "v1.3.2",
+ "version": "v1.3.6",
"source": {
"type": "git",
"url": "https://github.com/nette/schema.git",
- "reference": "da801d52f0354f70a638673c4a0f04e16529431d"
+ "reference": "c54350438cd6914616f790a49cb424605f421562"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nette/schema/zipball/da801d52f0354f70a638673c4a0f04e16529431d",
- "reference": "da801d52f0354f70a638673c4a0f04e16529431d",
+ "url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562",
+ "reference": "c54350438cd6914616f790a49cb424605f421562",
"shasum": ""
},
"require": {
"nette/utils": "^4.0",
- "php": "8.1 - 8.4"
+ "php": "8.1 - 8.5"
},
"require-dev": {
- "nette/tester": "^2.5.2",
- "phpstan/phpstan-nette": "^1.0",
+ "nette/phpstan-rules": "^1.0",
+ "nette/tester": "^2.6",
+ "phpstan/extension-installer": "^1.4@stable",
+ "phpstan/phpstan": "^2.1.39@stable",
"tracy/tracy": "^2.8"
},
"type": "library",
@@ -1154,6 +1174,9 @@
}
},
"autoload": {
+ "psr-4": {
+ "Nette\\": "src"
+ },
"classmap": [
"src/"
]
@@ -1182,38 +1205,40 @@
],
"support": {
"issues": "https://github.com/nette/schema/issues",
- "source": "https://github.com/nette/schema/tree/v1.3.2"
+ "source": "https://github.com/nette/schema/tree/v1.3.6"
},
- "time": "2024-10-06T23:10:23+00:00"
+ "time": "2026-08-16T21:58:41+00:00"
},
{
"name": "nette/security",
- "version": "v3.2.1",
+ "version": "v3.2.6",
"source": {
"type": "git",
"url": "https://github.com/nette/security.git",
- "reference": "6e19bf604934aec0cd3343a307e28fd997e40e96"
+ "reference": "6cad44dbac2153bccf36e73784a38c43d7cdae8b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nette/security/zipball/6e19bf604934aec0cd3343a307e28fd997e40e96",
- "reference": "6e19bf604934aec0cd3343a307e28fd997e40e96",
+ "url": "https://api.github.com/repos/nette/security/zipball/6cad44dbac2153bccf36e73784a38c43d7cdae8b",
+ "reference": "6cad44dbac2153bccf36e73784a38c43d7cdae8b",
"shasum": ""
},
"require": {
"nette/utils": "^4.0",
- "php": "8.1 - 8.4"
+ "php": "8.1 - 8.5"
},
"conflict": {
"nette/di": "<3.0-stable",
"nette/http": "<3.1.3"
},
"require-dev": {
- "mockery/mockery": "^1.5",
+ "mockery/mockery": "^1.6@stable",
"nette/di": "^3.1",
"nette/http": "^3.2",
- "nette/tester": "^2.5",
- "phpstan/phpstan-nette": "^1.0",
+ "nette/phpstan-rules": "^1.0",
+ "nette/tester": "^2.6",
+ "phpstan/extension-installer": "^1.4@stable",
+ "phpstan/phpstan": "^2.1@stable",
"tracy/tracy": "^2.9"
},
"type": "library",
@@ -1223,6 +1248,9 @@
}
},
"autoload": {
+ "psr-4": {
+ "Nette\\": "src"
+ },
"classmap": [
"src/"
]
@@ -1253,40 +1281,42 @@
],
"support": {
"issues": "https://github.com/nette/security/issues",
- "source": "https://github.com/nette/security/tree/v3.2.1"
+ "source": "https://github.com/nette/security/tree/v3.2.6"
},
- "time": "2024-11-04T12:25:05+00:00"
+ "time": "2026-07-24T23:36:00+00:00"
},
{
"name": "nette/utils",
- "version": "v4.0.5",
+ "version": "v4.1.5",
"source": {
"type": "git",
"url": "https://github.com/nette/utils.git",
- "reference": "736c567e257dbe0fcf6ce81b4d6dbe05c6899f96"
+ "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nette/utils/zipball/736c567e257dbe0fcf6ce81b4d6dbe05c6899f96",
- "reference": "736c567e257dbe0fcf6ce81b4d6dbe05c6899f96",
+ "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0",
+ "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0",
"shasum": ""
},
"require": {
- "php": "8.0 - 8.4"
+ "php": "8.2 - 8.5"
},
"conflict": {
"nette/finder": "<3",
"nette/schema": "<1.2.2"
},
"require-dev": {
- "jetbrains/phpstorm-attributes": "dev-master",
+ "jetbrains/phpstorm-attributes": "^1.2",
+ "nette/phpstan-rules": "^1.0",
"nette/tester": "^2.5",
- "phpstan/phpstan": "^1.0",
+ "phpstan/extension-installer": "^1.4@stable",
+ "phpstan/phpstan": "^2.1@stable",
"tracy/tracy": "^2.9"
},
"suggest": {
"ext-gd": "to use Image",
- "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()",
+ "ext-iconv": "to use Strings::chr(), ord() and reverse()",
"ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()",
"ext-json": "to use Nette\\Utils\\Json",
"ext-mbstring": "to use Strings::lower() etc...",
@@ -1295,10 +1325,13 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "4.0-dev"
+ "dev-master": "4.1-dev"
}
},
"autoload": {
+ "psr-4": {
+ "Nette\\": "src"
+ },
"classmap": [
"src/"
]
@@ -1339,9 +1372,9 @@
],
"support": {
"issues": "https://github.com/nette/utils/issues",
- "source": "https://github.com/nette/utils/tree/v4.0.5"
+ "source": "https://github.com/nette/utils/tree/v4.1.5"
},
- "time": "2024-08-07T15:39:19+00:00"
+ "time": "2026-07-17T23:02:45+00:00"
},
{
"name": "psr/cache",
@@ -1497,23 +1530,24 @@
},
{
"name": "symfony/console",
- "version": "v7.2.1",
+ "version": "v7.4.17",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3"
+ "reference": "962e18f09ebe68a49039b4c82fc0ea4871824fca"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/fefcc18c0f5d0efe3ab3152f15857298868dc2c3",
- "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3",
+ "url": "https://api.github.com/repos/symfony/console/zipball/962e18f09ebe68a49039b4c82fc0ea4871824fca",
+ "reference": "962e18f09ebe68a49039b4c82fc0ea4871824fca",
"shasum": ""
},
"require": {
"php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-mbstring": "~1.0",
"symfony/service-contracts": "^2.5|^3",
- "symfony/string": "^6.4|^7.0"
+ "symfony/string": "^7.2|^8.0"
},
"conflict": {
"symfony/dependency-injection": "<6.4",
@@ -1527,16 +1561,16 @@
},
"require-dev": {
"psr/log": "^1|^2|^3",
- "symfony/config": "^6.4|^7.0",
- "symfony/dependency-injection": "^6.4|^7.0",
- "symfony/event-dispatcher": "^6.4|^7.0",
- "symfony/http-foundation": "^6.4|^7.0",
- "symfony/http-kernel": "^6.4|^7.0",
- "symfony/lock": "^6.4|^7.0",
- "symfony/messenger": "^6.4|^7.0",
- "symfony/process": "^6.4|^7.0",
- "symfony/stopwatch": "^6.4|^7.0",
- "symfony/var-dumper": "^6.4|^7.0"
+ "symfony/config": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
+ "symfony/http-foundation": "^6.4|^7.0|^8.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/lock": "^6.4|^7.0|^8.0",
+ "symfony/messenger": "^6.4|^7.0|^8.0",
+ "symfony/process": "^6.4|^7.0|^8.0",
+ "symfony/stopwatch": "^6.4|^7.0|^8.0",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -1570,7 +1604,7 @@
"terminal"
],
"support": {
- "source": "https://github.com/symfony/console/tree/v7.2.1"
+ "source": "https://github.com/symfony/console/tree/v7.4.17"
},
"funding": [
{
@@ -1581,25 +1615,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-12-11T03:49:26+00:00"
+ "time": "2026-08-21T12:09:28+00:00"
},
{
"name": "symfony/deprecation-contracts",
- "version": "v3.5.1",
+ "version": "v3.7.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
- "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6"
+ "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6",
- "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d",
+ "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d",
"shasum": ""
},
"require": {
@@ -1612,7 +1650,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.5-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -1637,7 +1675,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1"
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1"
},
"funding": [
{
@@ -1648,25 +1686,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-25T14:20:29+00:00"
+ "time": "2026-06-05T06:23:12+00:00"
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.31.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
- "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
- "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2",
"shasum": ""
},
"require": {
@@ -1716,7 +1758,7 @@
"portable"
],
"support": {
- "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0"
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
},
"funding": [
{
@@ -1727,25 +1769,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/polyfill-intl-grapheme",
- "version": "v1.31.0",
+ "version": "v1.41.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
- "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe"
+ "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe",
- "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d",
+ "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d",
"shasum": ""
},
"require": {
@@ -1794,7 +1840,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0"
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0"
},
"funding": [
{
@@ -1805,25 +1851,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-07-28T08:25:59+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
- "version": "v1.31.0",
+ "version": "v1.42.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
- "reference": "3833d7255cc303546435cb650316bff708a1c75c"
+ "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
- "reference": "3833d7255cc303546435cb650316bff708a1c75c",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502",
+ "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502",
"shasum": ""
},
"require": {
@@ -1875,7 +1925,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0"
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0"
},
"funding": [
{
@@ -1886,28 +1936,33 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-08-07T06:33:24+00:00"
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.31.0",
+ "version": "v1.38.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
- "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341"
+ "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341",
- "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
+ "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
"shasum": ""
},
"require": {
+ "ext-iconv": "*",
"php": ">=7.2"
},
"provide": {
@@ -1955,7 +2010,87 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0"
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-27T06:59:30+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php84",
+ "version": "v1.38.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php84.git",
+ "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa",
+ "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php84\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1"
},
"funding": [
{
@@ -1966,25 +2101,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-05-26T12:51:13+00:00"
},
{
"name": "symfony/service-contracts",
- "version": "v3.5.1",
+ "version": "v3.7.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
- "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0"
+ "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0",
- "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0",
+ "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0",
"shasum": ""
},
"require": {
@@ -2002,7 +2141,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.5-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -2038,7 +2177,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/service-contracts/tree/v3.5.1"
+ "source": "https://github.com/symfony/service-contracts/tree/v3.7.1"
},
"funding": [
{
@@ -2049,44 +2188,47 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-25T14:20:29+00:00"
+ "time": "2026-06-16T09:55:08+00:00"
},
{
"name": "symfony/string",
- "version": "v7.2.0",
+ "version": "v8.1.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
- "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82"
+ "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82",
- "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82",
+ "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc",
+ "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc",
"shasum": ""
},
"require": {
- "php": ">=8.2",
- "symfony/polyfill-ctype": "~1.8",
- "symfony/polyfill-intl-grapheme": "~1.0",
- "symfony/polyfill-intl-normalizer": "~1.0",
- "symfony/polyfill-mbstring": "~1.0"
+ "php": ">=8.4.1",
+ "symfony/polyfill-ctype": "^1.8",
+ "symfony/polyfill-intl-grapheme": "^1.33",
+ "symfony/polyfill-intl-normalizer": "^1.0",
+ "symfony/polyfill-mbstring": "^1.0"
},
"conflict": {
"symfony/translation-contracts": "<2.5"
},
"require-dev": {
- "symfony/emoji": "^7.1",
- "symfony/error-handler": "^6.4|^7.0",
- "symfony/http-client": "^6.4|^7.0",
- "symfony/intl": "^6.4|^7.0",
+ "symfony/emoji": "^7.4|^8.0",
+ "symfony/http-client": "^7.4|^8.0",
+ "symfony/intl": "^7.4|^8.0",
"symfony/translation-contracts": "^2.5|^3.0",
- "symfony/var-exporter": "^6.4|^7.0"
+ "symfony/var-exporter": "^7.4|^8.0"
},
"type": "library",
"autoload": {
@@ -2125,7 +2267,7 @@
"utf8"
],
"support": {
- "source": "https://github.com/symfony/string/tree/v7.2.0"
+ "source": "https://github.com/symfony/string/tree/v8.1.2"
},
"funding": [
{
@@ -2136,34 +2278,39 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-11-13T13:31:26+00:00"
+ "time": "2026-07-28T07:35:25+00:00"
},
{
"name": "symfony/var-exporter",
- "version": "v7.2.0",
+ "version": "v7.4.16",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-exporter.git",
- "reference": "1a6a89f95a46af0f142874c9d650a6358d13070d"
+ "reference": "ca31404415670aa3834809005b529df1b84f0790"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/var-exporter/zipball/1a6a89f95a46af0f142874c9d650a6358d13070d",
- "reference": "1a6a89f95a46af0f142874c9d650a6358d13070d",
+ "url": "https://api.github.com/repos/symfony/var-exporter/zipball/ca31404415670aa3834809005b529df1b84f0790",
+ "reference": "ca31404415670aa3834809005b529df1b84f0790",
"shasum": ""
},
"require": {
- "php": ">=8.2"
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3"
},
"require-dev": {
- "symfony/property-access": "^6.4|^7.0",
- "symfony/serializer": "^6.4|^7.0",
- "symfony/var-dumper": "^6.4|^7.0"
+ "symfony/property-access": "^6.4|^7.0|^8.0",
+ "symfony/serializer": "^6.4|^7.0|^8.0",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -2201,7 +2348,7 @@
"serialize"
],
"support": {
- "source": "https://github.com/symfony/var-exporter/tree/v7.2.0"
+ "source": "https://github.com/symfony/var-exporter/tree/v7.4.16"
},
"funding": [
{
@@ -2212,23 +2359,2129 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-10-18T07:58:17+00:00"
+ "time": "2026-07-30T12:37:26+00:00"
+ }
+ ],
+ "packages-dev": [
+ {
+ "name": "myclabs/deep-copy",
+ "version": "1.14.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/myclabs/DeepCopy.git",
+ "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae",
+ "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.0"
+ },
+ "conflict": {
+ "doctrine/collections": "<1.6.8",
+ "doctrine/common": "<2.13.3 || >=3 <3.2.2"
+ },
+ "require-dev": {
+ "doctrine/collections": "^1.6.8",
+ "doctrine/common": "^2.13.3 || ^3.2.2",
+ "phpspec/prophecy": "^1.10",
+ "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/DeepCopy/deep_copy.php"
+ ],
+ "psr-4": {
+ "DeepCopy\\": "src/DeepCopy/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Create deep copies (clones) of your objects",
+ "keywords": [
+ "clone",
+ "copy",
+ "duplicate",
+ "object",
+ "object graph"
+ ],
+ "support": {
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/mnapoli",
+ "type": "github"
+ }
+ ],
+ "time": "2026-08-11T10:17:44+00:00"
+ },
+ {
+ "name": "nikic/php-parser",
+ "version": "v5.8.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nikic/PHP-Parser.git",
+ "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
+ "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-tokenizer": "*",
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "ircmaxell/php-yacc": "^0.0.7",
+ "phpunit/phpunit": "^9.0"
+ },
+ "bin": [
+ "bin/php-parse"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PhpParser\\": "lib/PhpParser"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Nikita Popov"
+ }
+ ],
+ "description": "A PHP parser written in PHP",
+ "keywords": [
+ "parser",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/nikic/PHP-Parser/issues",
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0"
+ },
+ "time": "2026-07-04T14:30:18+00:00"
+ },
+ {
+ "name": "phar-io/manifest",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/manifest.git",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-phar": "*",
+ "ext-xmlwriter": "*",
+ "phar-io/version": "^3.0.1",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+ "support": {
+ "issues": "https://github.com/phar-io/manifest/issues",
+ "source": "https://github.com/phar-io/manifest/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-03T12:33:53+00:00"
+ },
+ {
+ "name": "phar-io/version",
+ "version": "3.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/version.git",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Library for handling version information and constraints",
+ "support": {
+ "issues": "https://github.com/phar-io/version/issues",
+ "source": "https://github.com/phar-io/version/tree/3.2.1"
+ },
+ "time": "2022-02-21T01:04:05+00:00"
+ },
+ {
+ "name": "phpunit/php-code-coverage",
+ "version": "14.3.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "reference": "6ce313bb110384148d1dc7695a99175f59529069"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/6ce313bb110384148d1dc7695a99175f59529069",
+ "reference": "6ce313bb110384148d1dc7695a99175f59529069",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-xmlwriter": "*",
+ "nikic/php-parser": "^5.8.0",
+ "php": ">=8.4",
+ "phpunit/php-text-template": "^6.0",
+ "sebastian/complexity": "^6.0",
+ "sebastian/environment": "^9.3.2",
+ "sebastian/git-state": "^1.0",
+ "sebastian/lines-of-code": "^5.0.2",
+ "sebastian/version": "^7.0",
+ "theseer/tokenizer": "^2.0.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.3.1"
+ },
+ "suggest": {
+ "ext-pcov": "PHP extension that provides line coverage",
+ "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "14.3.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+ "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+ "keywords": [
+ "coverage",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+ "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.3.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-16T05:23:47+00:00"
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "version": "7.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+ "reference": "9bb4e6c58b62c1e043be995c66abec7c97307aae"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/9bb4e6c58b62c1e043be995c66abec7c97307aae",
+ "reference": "9bb4e6c58b62c1e043be995c66abec7c97307aae",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.3.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+ "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+ "keywords": [
+ "filesystem",
+ "iterator"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+ "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-25T14:47:43+00:00"
+ },
+ {
+ "name": "phpunit/php-invoker",
+ "version": "7.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-invoker.git",
+ "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88",
+ "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "ext-pcntl": "*",
+ "phpunit/phpunit": "^13.0"
+ },
+ "suggest": {
+ "ext-pcntl": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Invoke callables with a timeout",
+ "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+ "keywords": [
+ "process"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+ "security": "https://github.com/sebastianbergmann/php-invoker/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:34:47+00:00"
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "version": "6.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-text-template.git",
+ "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4",
+ "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Simple template engine.",
+ "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+ "keywords": [
+ "template"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+ "security": "https://github.com/sebastianbergmann/php-text-template/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:36:37+00:00"
+ },
+ {
+ "name": "phpunit/php-timer",
+ "version": "9.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-timer.git",
+ "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6",
+ "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "9.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Utility class for timing",
+ "homepage": "https://github.com/sebastianbergmann/php-timer/",
+ "keywords": [
+ "timer"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+ "security": "https://github.com/sebastianbergmann/php-timer/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:37:53+00:00"
+ },
+ {
+ "name": "phpunit/phpunit",
+ "version": "13.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit.git",
+ "reference": "22104a5ceb8d642e6b30ab00211d53d88e2db368"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/22104a5ceb8d642e6b30ab00211d53d88e2db368",
+ "reference": "22104a5ceb8d642e6b30ab00211d53d88e2db368",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-filter": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-xmlwriter": "*",
+ "myclabs/deep-copy": "^1.14.0",
+ "phar-io/manifest": "^2.0.4",
+ "phar-io/version": "^3.2.1",
+ "php": ">=8.4.1",
+ "phpunit/php-code-coverage": "^14.3.1",
+ "phpunit/php-file-iterator": "^7.0.2",
+ "phpunit/php-invoker": "^7.0.0",
+ "phpunit/php-text-template": "^6.0.0",
+ "phpunit/php-timer": "^9.0.0",
+ "sebastian/cli-parser": "^5.0.1",
+ "sebastian/comparator": "^8.4",
+ "sebastian/diff": "^9.0",
+ "sebastian/environment": "^9.3.2",
+ "sebastian/exporter": "^8.2.1",
+ "sebastian/file-filter": "^1.0",
+ "sebastian/git-state": "^1.0",
+ "sebastian/global-state": "^9.0.1",
+ "sebastian/object-enumerator": "^8.1.0",
+ "sebastian/recursion-context": "^8.0.1",
+ "sebastian/type": "^7.0.2",
+ "sebastian/version": "^7.0.0",
+ "staabm/side-effects-detector": "^1.0.5"
+ },
+ "bin": [
+ "phpunit"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "13.3-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Framework/Assert/Functions.php"
+ ],
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "The PHP Unit Testing framework.",
+ "homepage": "https://phpunit.de/",
+ "keywords": [
+ "phpunit",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+ "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/13.3.2"
+ },
+ "funding": [
+ {
+ "url": "https://phpunit.de/sponsoring.html",
+ "type": "other"
+ }
+ ],
+ "time": "2026-08-27T08:40:49+00:00"
+ },
+ {
+ "name": "sebastian/cli-parser",
+ "version": "5.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/cli-parser.git",
+ "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/eeb759ad3146b7096fb59c3195d39e071cd409e3",
+ "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.2.6"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for parsing CLI options",
+ "homepage": "https://github.com/sebastianbergmann/cli-parser",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+ "security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-01T04:27:14+00:00"
+ },
+ {
+ "name": "sebastian/comparator",
+ "version": "8.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/comparator.git",
+ "reference": "3b070e608146cba00fd6fd1f0ffba89e5a8897fb"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/3b070e608146cba00fd6fd1f0ffba89e5a8897fb",
+ "reference": "3b070e608146cba00fd6fd1f0ffba89e5a8897fb",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-mbstring": "*",
+ "php": ">=8.4",
+ "sebastian/diff": "^9.0",
+ "sebastian/exporter": "^8.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.3"
+ },
+ "suggest": {
+ "ext-bcmath": "For comparing BcMath\\Number objects"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.4-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@2bepublished.at"
+ }
+ ],
+ "description": "Provides the functionality to compare PHP values for equality",
+ "homepage": "https://github.com/sebastianbergmann/comparator",
+ "keywords": [
+ "comparator",
+ "compare",
+ "equality"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/comparator/issues",
+ "security": "https://github.com/sebastianbergmann/comparator/security/policy",
+ "source": "https://github.com/sebastianbergmann/comparator/tree/8.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-07T07:23:13+00:00"
+ },
+ {
+ "name": "sebastian/complexity",
+ "version": "6.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/complexity.git",
+ "reference": "c5651c795c98093480df79350cb050813fc7a2f3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3",
+ "reference": "c5651c795c98093480df79350cb050813fc7a2f3",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^5.0",
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for calculating the complexity of PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/complexity",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/complexity/issues",
+ "security": "https://github.com/sebastianbergmann/complexity/security/policy",
+ "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:41:32+00:00"
+ },
+ {
+ "name": "sebastian/diff",
+ "version": "9.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/diff.git",
+ "reference": "a2df6626c1baf31d5a88674882a3072f151b5a26"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a2df6626c1baf31d5a88674882a3072f151b5a26",
+ "reference": "a2df6626c1baf31d5a88674882a3072f151b5a26",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.3.1",
+ "symfony/process": "^7.4.17"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "9.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Kore Nordmann",
+ "email": "mail@kore-nordmann.de"
+ }
+ ],
+ "description": "Diff implementation",
+ "homepage": "https://github.com/sebastianbergmann/diff",
+ "keywords": [
+ "diff",
+ "udiff",
+ "unidiff",
+ "unified diff"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/diff/issues",
+ "security": "https://github.com/sebastianbergmann/diff/security/policy",
+ "source": "https://github.com/sebastianbergmann/diff/tree/9.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/diff",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-25T15:38:55+00:00"
+ },
+ {
+ "name": "sebastian/environment",
+ "version": "9.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/environment.git",
+ "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e",
+ "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.1.11"
+ },
+ "suggest": {
+ "ext-posix": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "9.3-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides functionality to handle HHVM/PHP environments",
+ "homepage": "https://github.com/sebastianbergmann/environment",
+ "keywords": [
+ "Xdebug",
+ "environment",
+ "hhvm"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/environment/issues",
+ "security": "https://github.com/sebastianbergmann/environment/security/policy",
+ "source": "https://github.com/sebastianbergmann/environment/tree/9.3.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/environment",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-25T13:41:38+00:00"
+ },
+ {
+ "name": "sebastian/exporter",
+ "version": "8.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/exporter.git",
+ "reference": "24a3b69bba4a12ab615fca9d34680c5598d9ab7a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/24a3b69bba4a12ab615fca9d34680c5598d9ab7a",
+ "reference": "24a3b69bba4a12ab615fca9d34680c5598d9ab7a",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": ">=8.4",
+ "sebastian/recursion-context": "^8.0.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.2-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Provides the functionality to export PHP variables for visualization",
+ "homepage": "https://www.github.com/sebastianbergmann/exporter",
+ "keywords": [
+ "export",
+ "exporter"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/exporter/issues",
+ "security": "https://github.com/sebastianbergmann/exporter/security/policy",
+ "source": "https://github.com/sebastianbergmann/exporter/tree/8.2.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-07T07:22:06+00:00"
+ },
+ {
+ "name": "sebastian/file-filter",
+ "version": "1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/file-filter.git",
+ "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/33a26f394330f6faa7684bb9cc73afb7727aae93",
+ "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for filtering files",
+ "homepage": "https://github.com/sebastianbergmann/file-filter",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/file-filter/issues",
+ "security": "https://github.com/sebastianbergmann/file-filter/security/policy",
+ "source": "https://github.com/sebastianbergmann/file-filter/tree/1.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/file-filter",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-22T07:20:04+00:00"
+ },
+ {
+ "name": "sebastian/git-state",
+ "version": "1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/git-state.git",
+ "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b",
+ "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for describing the state of a Git checkout",
+ "homepage": "https://github.com/sebastianbergmann/git-state",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/git-state/issues",
+ "security": "https://github.com/sebastianbergmann/git-state/security/policy",
+ "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-03-21T12:54:28+00:00"
+ },
+ {
+ "name": "sebastian/global-state",
+ "version": "9.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/global-state.git",
+ "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945",
+ "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4",
+ "sebastian/object-reflector": "^6.0",
+ "sebastian/recursion-context": "^8.0"
+ },
+ "require-dev": {
+ "ext-dom": "*",
+ "phpunit/phpunit": "^13.1.13"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "9.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Snapshotting of global state",
+ "homepage": "https://www.github.com/sebastianbergmann/global-state",
+ "keywords": [
+ "global state"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/global-state/issues",
+ "security": "https://github.com/sebastianbergmann/global-state/security/policy",
+ "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-06-01T15:11:33+00:00"
+ },
+ {
+ "name": "sebastian/lines-of-code",
+ "version": "5.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+ "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d1b6f8fce682505dbd048977f1abedf1b8ad3ff8",
+ "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^5.8.0",
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.2.4"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for counting the lines of code in PHP source code",
+ "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+ "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-07-09T08:42:34+00:00"
+ },
+ {
+ "name": "sebastian/object-enumerator",
+ "version": "8.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+ "reference": "511064ecde82bd747e2ba2fab3dda8d977b59576"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/511064ecde82bd747e2ba2fab3dda8d977b59576",
+ "reference": "511064ecde82bd747e2ba2fab3dda8d977b59576",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4",
+ "sebastian/recursion-context": "^8.0.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.3.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+ "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+ "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy",
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-13T07:05:05+00:00"
+ },
+ {
+ "name": "sebastian/object-reflector",
+ "version": "6.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-reflector.git",
+ "reference": "f71bbcdc4f95456b4622810bec64eb06372e25b2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/f71bbcdc4f95456b4622810bec64eb06372e25b2",
+ "reference": "f71bbcdc4f95456b4622810bec64eb06372e25b2",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.3.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Allows reflection of object attributes, including inherited and non-public ones",
+ "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+ "security": "https://github.com/sebastianbergmann/object-reflector/security/policy",
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-13T06:34:36+00:00"
+ },
+ {
+ "name": "sebastian/recursion-context",
+ "version": "8.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/recursion-context.git",
+ "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0",
+ "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.2.6"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ }
+ ],
+ "description": "Provides functionality to recursively process PHP variables",
+ "homepage": "https://github.com/sebastianbergmann/recursion-context",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+ "security": "https://github.com/sebastianbergmann/recursion-context/security/policy",
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-03T05:58:12+00:00"
+ },
+ {
+ "name": "sebastian/type",
+ "version": "7.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/type.git",
+ "reference": "bd1df467864cb95140414059a535b2d906173fcf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/bd1df467864cb95140414059a535b2d906173fcf",
+ "reference": "bd1df467864cb95140414059a535b2d906173fcf",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.3.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the types of the PHP type system",
+ "homepage": "https://github.com/sebastianbergmann/type",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/type/issues",
+ "security": "https://github.com/sebastianbergmann/type/security/policy",
+ "source": "https://github.com/sebastianbergmann/type/tree/7.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/type",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-10T08:00:57+00:00"
+ },
+ {
+ "name": "sebastian/version",
+ "version": "7.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/version.git",
+ "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b",
+ "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+ "homepage": "https://github.com/sebastianbergmann/version",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/version/issues",
+ "security": "https://github.com/sebastianbergmann/version/security/policy",
+ "source": "https://github.com/sebastianbergmann/version/tree/7.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/version",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:52:52+00:00"
+ },
+ {
+ "name": "staabm/side-effects-detector",
+ "version": "1.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/staabm/side-effects-detector.git",
+ "reference": "d8334211a140ce329c13726d4a715adbddd0a163"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163",
+ "reference": "d8334211a140ce329c13726d4a715adbddd0a163",
+ "shasum": ""
+ },
+ "require": {
+ "ext-tokenizer": "*",
+ "php": "^7.4 || ^8.0"
+ },
+ "require-dev": {
+ "phpstan/extension-installer": "^1.4.3",
+ "phpstan/phpstan": "^1.12.6",
+ "phpunit/phpunit": "^9.6.21",
+ "symfony/var-dumper": "^5.4.43",
+ "tomasvotruba/type-coverage": "1.0.0",
+ "tomasvotruba/unused-public": "1.0.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "lib/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A static analysis tool to detect side effects in PHP code",
+ "keywords": [
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/staabm/side-effects-detector/issues",
+ "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/staabm",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-20T05:08:20+00:00"
+ },
+ {
+ "name": "symfony/cache",
+ "version": "v7.4.17",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/cache.git",
+ "reference": "f6028442dd1dfa4f88e9c7360d753948d165e938"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/cache/zipball/f6028442dd1dfa4f88e9c7360d753948d165e938",
+ "reference": "f6028442dd1dfa4f88e9c7360d753948d165e938",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "psr/cache": "^2.0|^3.0",
+ "psr/log": "^1.1|^2|^3",
+ "symfony/cache-contracts": "^3.6",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/service-contracts": "^2.5|^3",
+ "symfony/var-exporter": "^6.4|^7.0|^8.0"
+ },
+ "conflict": {
+ "doctrine/dbal": "<3.6",
+ "ext-relay": "<0.12.1",
+ "symfony/dependency-injection": "<6.4",
+ "symfony/http-kernel": "<6.4",
+ "symfony/var-dumper": "<6.4"
+ },
+ "provide": {
+ "psr/cache-implementation": "2.0|3.0",
+ "psr/simple-cache-implementation": "1.0|2.0|3.0",
+ "symfony/cache-implementation": "1.1|2.0|3.0"
+ },
+ "require-dev": {
+ "cache/integration-tests": "^1.0.3",
+ "doctrine/dbal": "^3.6|^4",
+ "predis/predis": "^1.1|^2.0",
+ "psr/simple-cache": "^1.0|^2.0|^3.0",
+ "symfony/clock": "^6.4|^7.0|^8.0",
+ "symfony/config": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/filesystem": "^6.4|^7.0|^8.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/messenger": "^6.4|^7.0|^8.0",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Cache\\": ""
+ },
+ "classmap": [
+ "Traits/ValueWrapper.php"
+ ],
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides extended PSR-6, PSR-16 (and tags) implementations",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "caching",
+ "psr6"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/cache/tree/v7.4.17"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-19T08:28:05+00:00"
+ },
+ {
+ "name": "symfony/cache-contracts",
+ "version": "v3.7.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/cache-contracts.git",
+ "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2",
+ "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/cache": "^3.0"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.7-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\Cache\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to caching",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-06-05T06:23:12+00:00"
+ },
+ {
+ "name": "theseer/tokenizer",
+ "version": "2.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theseer/tokenizer.git",
+ "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
+ "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": "^8.1"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+ "support": {
+ "issues": "https://github.com/theseer/tokenizer/issues",
+ "source": "https://github.com/theseer/tokenizer/tree/2.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2025-12-08T11:19:18+00:00"
}
],
- "packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
- "php": "^8.0"
+ "php": "^8.0|^8.1|^8.2"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..cf811be
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,31 @@
+
+
+
+
+ tests
+
+
+
+
+
+ src
+
+
+
diff --git a/src/ChangeSet/ChangeSet.php b/src/ChangeSet/ChangeSet.php
index 161dd18..1b07707 100644
--- a/src/ChangeSet/ChangeSet.php
+++ b/src/ChangeSet/ChangeSet.php
@@ -1,6 +1,6 @@
p as $name => $propertyChangeSet) {
- $propertyChangeSet->setName($name);
- }
+ $this->p[$property->getName()] = $property;
}
/**
diff --git a/src/ChangeSet/Id.php b/src/ChangeSet/Id.php
index e78ec48..c5e4016 100644
--- a/src/ChangeSet/Id.php
+++ b/src/ChangeSet/Id.php
@@ -1,6 +1,6 @@
a = array_values($added);
+ $this->r = array_values($removed);
+ $this->ch = array_values($changeSets);
}
}
diff --git a/src/ChangeSet/ToOne.php b/src/ChangeSet/ToOne.php
index 5aec16e..3452e3c 100644
--- a/src/ChangeSet/ToOne.php
+++ b/src/ChangeSet/ToOne.php
@@ -1,6 +1,6 @@
ch = $changeSet;
}
/**
diff --git a/src/Console/ConvertLegacyChangeSetsCommand.php b/src/Console/ConvertLegacyChangeSetsCommand.php
new file mode 100644
index 0000000..b64af54
--- /dev/null
+++ b/src/Console/ConvertLegacyChangeSetsCommand.php
@@ -0,0 +1,81 @@
+converter = $converter;
+ }
+
+ protected function configure(): void
+ {
+ $this
+ ->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'How many rows to convert per transaction', '500')
+ ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Read and convert everything, but roll back instead of writing');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $dryRun = (bool) $input->getOption('dry-run');
+ $batchSize = (int) $input->getOption('batch-size');
+ $rows = $this->converter->countRows();
+
+ $output->writeln(($dryRun ? '[dry run] ' : '') . "Converting {$rows} change_log rows in batches of {$batchSize}.");
+
+ $result = $this->converter->convert(
+ $batchSize,
+ function (LegacyChangeSetConversionResult $batch) use ($output): void {
+ $output->writeln(sprintf(
+ ' batch: %d converted, %d already json, %d failed',
+ $batch->getConverted(),
+ $batch->getSkipped(),
+ count($batch->getFailures())
+ ));
+ },
+ $dryRun
+ );
+
+ $output->writeln(sprintf(
+ 'Done: %d converted, %d already json, %d failed.',
+ $result->getConverted(),
+ $result->getSkipped(),
+ count($result->getFailures())
+ ));
+
+ foreach ($result->getFailures() as $id => $message) {
+ $output->writeln(" row {$id}: {$message}");
+ }
+
+ if ($result->hasFailures()) {
+ $output->writeln('Rows that failed were left untouched, so the column cannot be changed to JSON yet.');
+
+ return Command::FAILURE;
+ }
+
+ if (!$dryRun) {
+ $output->writeln('Now change the column type: ALTER TABLE change_log MODIFY change_set JSON NOT NULL;');
+ }
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/DI/LoggableExtension.php b/src/DI/LoggableExtension.php
index e7b7a02..55accea 100644
--- a/src/DI/LoggableExtension.php
+++ b/src/DI/LoggableExtension.php
@@ -2,19 +2,58 @@
namespace ADT\DoctrineLoggable\DI;
+use ADT\DoctrineLoggable\Console\ConvertLegacyChangeSetsCommand;
+use ADT\DoctrineLoggable\Doctrine\ChangeSetType;
use ADT\DoctrineLoggable\Listener\LoggableListener;
+use ADT\DoctrineLoggable\Serializer\ChangeSetSerializer;
+use ADT\DoctrineLoggable\Serializer\ValueSerializer;
use ADT\DoctrineLoggable\Service\ChangeSetFactory;
+use ADT\DoctrineLoggable\Service\LegacyChangeSetConverter;
use Doctrine\Common\EventManager;
+use Symfony\Component\Console\Command\Command;
use Nette\DI\CompilerExtension;
+use Nette\PhpGenerator\ClassType;
+use Nette\Schema\Expect;
+use Nette\Schema\Schema;
class LoggableExtension extends CompilerExtension
{
+ public function getConfigSchema(): Schema
+ {
+ return Expect::structure([
+ // ADT\DoctrineLoggable\Serializer\ValueHandler implementations, tried before the built-in ones
+ 'valueHandlers' => Expect::listOf(Expect::string()->dynamic()),
+ ]);
+ }
+
public function loadConfiguration(): void
{
$builder = $this->getContainerBuilder();
$builder->addDefinition($this->prefix('changeSetFactory'))
->setFactory(ChangeSetFactory::class);
+
+ $handlers = [];
+ foreach ($this->config->valueHandlers as $index => $handler) {
+ $handlers[] = $builder->addDefinition($this->prefix('valueHandler.' . $index))
+ ->setFactory($handler)
+ ->setAutowired(false);
+ }
+
+ $builder->addDefinition($this->prefix('valueSerializer'))
+ ->setFactory(ValueSerializer::class, [$handlers]);
+
+ $builder->addDefinition($this->prefix('changeSetSerializer'))
+ ->setFactory(ChangeSetSerializer::class);
+
+ $builder->addDefinition($this->prefix('legacyChangeSetConverter'))
+ ->setFactory(LegacyChangeSetConverter::class);
+
+ // symfony/console is optional, the library works without it
+ if (class_exists(Command::class)) {
+ $builder->addDefinition($this->prefix('convertLegacyChangeSetsCommand'))
+ ->setFactory(ConvertLegacyChangeSetsCommand::class);
+ }
}
public function beforeCompile(): void
@@ -28,4 +67,13 @@ public function beforeCompile(): void
$builder->getDefinition($builder->getByType(EventManager::class))
->addSetup('addEventSubscriber', ['@' . $this->prefix('listener')]);
}
+
+ public function afterCompile(ClassType $class): void
+ {
+ // the dbal type has to know the serializer before the first entity is loaded or persisted
+ $this->initialization->addBody(
+ '\\' . ChangeSetType::class . '::register($this->getService(?));',
+ [$this->prefix('changeSetSerializer')]
+ );
+ }
}
diff --git a/src/Doctrine/ChangeSetType.php b/src/Doctrine/ChangeSetType.php
new file mode 100644
index 0000000..47e562d
--- /dev/null
+++ b/src/Doctrine/ChangeSetType.php
@@ -0,0 +1,99 @@
+serializer = $serializer;
+ }
+
+ /**
+ * Registers the type. Safe to call repeatedly, the last registration wins.
+ */
+ public static function register(?ChangeSetSerializer $serializer = null): void
+ {
+ $type = new self($serializer);
+
+ if (Type::hasType(self::NAME)) {
+ Type::overrideType(self::NAME, $type);
+ } else {
+ Type::addType(self::NAME, $type);
+ }
+ }
+
+ public function getSerializer(): ChangeSetSerializer
+ {
+ return $this->serializer ??= new ChangeSetSerializer();
+ }
+
+ public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): ?string
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ if (!$value instanceof ChangeSet) {
+ throw InvalidType::new($value, self::NAME, [ChangeSet::class, 'null']);
+ }
+
+ try {
+ return json_encode($this->getSerializer()->toArray($value), self::ENCODE_FLAGS);
+ } catch (JsonException $e) {
+ throw SerializationFailed::new($value, 'json', $e->getMessage(), $e);
+ }
+ }
+
+ public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?ChangeSet
+ {
+ if ($value === null || $value === '') {
+ return null;
+ }
+
+ if ($value instanceof ChangeSet) {
+ return $value;
+ }
+
+ if (is_resource($value)) {
+ $value = stream_get_contents($value);
+ }
+
+ try {
+ $data = json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException $e) {
+ throw ValueNotConvertible::new($value, self::NAME, $e->getMessage(), $e);
+ }
+
+ if (!is_array($data)) {
+ throw ValueNotConvertible::new($value, self::NAME, 'The stored value is not a JSON object.');
+ }
+
+ return $this->getSerializer()->fromArray($data);
+ }
+}
diff --git a/src/Entity/ChangeLog.php b/src/Entity/ChangeLog.php
index 3e1d903..5e32b39 100644
--- a/src/Entity/ChangeLog.php
+++ b/src/Entity/ChangeLog.php
@@ -3,6 +3,7 @@
namespace ADT\DoctrineLoggable\Entity;
use ADT\DoctrineLoggable\ChangeSet\ChangeSet;
+use ADT\DoctrineLoggable\Doctrine\ChangeSetType;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
@@ -29,8 +30,8 @@ class ChangeLog
#[ORM\Column(nullable: true)]
private ?int $objectId = null;
- #[ORM\Column(type: 'blob', nullable: false)]
- private $changeSet; // string/resource
+ #[ORM\Column(type: ChangeSetType::NAME, nullable: false)]
+ private ChangeSet $changeSet;
#[ORM\Column(nullable: true)]
private ?string $identityClass = null;
@@ -85,14 +86,12 @@ public function setObjectId(?int $objectId): void
public function getChangeSet(): ChangeSet
{
- $this->changeSet = is_resource($this->changeSet) ? stream_get_contents($this->changeSet) : $this->changeSet;
-
- return unserialize($this->changeSet);
+ return $this->changeSet;
}
public function setChangeSet(ChangeSet $changeSet): void
{
- $this->changeSet = serialize($changeSet);
+ $this->changeSet = $changeSet;
}
public function getIdentityClass(): ?string
diff --git a/src/Listener/LoggableListener.php b/src/Listener/LoggableListener.php
index fc91c1e..150edea 100644
--- a/src/Listener/LoggableListener.php
+++ b/src/Listener/LoggableListener.php
@@ -5,6 +5,7 @@
use ADT\DoctrineLoggable\Service\ChangeSetFactory;
use Doctrine\Common\EventSubscriber;
use Doctrine\Common\Util\ClassUtils;
+use Doctrine\ORM\Event\OnClearEventArgs;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostPersistEventArgs;
use Doctrine\ORM\Exception\ORMException;
@@ -25,6 +26,7 @@ function getSubscribedEvents(): array
return [
'onFlush',
'postPersist',
+ 'onClear',
];
}
@@ -68,4 +70,9 @@ public function postPersist(PostPersistEventArgs $args): void
$object = $args->getObject();
$this->changeSetFactory->updateIdentification($object);
}
+
+ public function onClear(OnClearEventArgs $args): void
+ {
+ $this->changeSetFactory->onClear();
+ }
}
diff --git a/src/Rendering/ChangeSetRenderer.php b/src/Rendering/ChangeSetRenderer.php
index 092f29a..ca90196 100644
--- a/src/Rendering/ChangeSetRenderer.php
+++ b/src/Rendering/ChangeSetRenderer.php
@@ -18,7 +18,7 @@ class ChangeSetRenderer
public function render(ChangeSet $changeSet)
{
$this->renderChangeSet($changeSet);
- $this->changesetRendered = [];
+ $this->changeSetsRendered = [];
}
protected function renderChangeSet(ChangeSet $changeSet)
diff --git a/src/Serializer/ChangeSetSerializer.php b/src/Serializer/ChangeSetSerializer.php
new file mode 100644
index 0000000..709319c
--- /dev/null
+++ b/src/Serializer/ChangeSetSerializer.php
@@ -0,0 +1,277 @@
+valueSerializer = $valueSerializer ?? new ValueSerializer();
+ }
+
+ public function getValueSerializer(): ValueSerializer
+ {
+ return $this->valueSerializer;
+ }
+
+ /**
+ * @return array
+ */
+ public function toArray(ChangeSet $changeSet): array
+ {
+ $counts = [];
+ $this->countReferences($changeSet, $counts);
+
+ $ids = [];
+ $emitted = [];
+
+ return ['version' => self::VERSION] + $this->encodeChangeSet($changeSet, $counts, $ids, $emitted);
+ }
+
+ /**
+ * @param array $data
+ */
+ public function fromArray(array $data): ChangeSet
+ {
+ $version = $data['version'] ?? 0;
+ if ($version > self::VERSION) {
+ throw new UnexpectedValueException("Change set format version {$version} is newer than the supported version " . self::VERSION . '.');
+ }
+
+ $references = [];
+
+ return $this->decodeChangeSet($data, $references);
+ }
+
+ /**
+ * @param array $counts
+ */
+ private function countReferences(ChangeSet $changeSet, array &$counts): void
+ {
+ $oid = spl_object_id($changeSet);
+ $counts[$oid] = ($counts[$oid] ?? 0) + 1;
+
+ if ($counts[$oid] > 1) {
+ return;
+ }
+
+ foreach ($changeSet->getChangedProperties() as $property) {
+ if ($property instanceof ToOne) {
+ if ($property->getChangeSet() !== null) {
+ $this->countReferences($property->getChangeSet(), $counts);
+ }
+ } elseif ($property instanceof ToMany) {
+ foreach ($property->getChangeSets() as $nested) {
+ $this->countReferences($nested, $counts);
+ }
+ }
+ }
+ }
+
+ /**
+ * @param array $counts
+ * @param array $ids
+ * @param array $emitted
+ * @return array
+ */
+ private function encodeChangeSet(ChangeSet $changeSet, array $counts, array &$ids, array &$emitted): array
+ {
+ $oid = spl_object_id($changeSet);
+
+ if (isset($emitted[$oid])) {
+ return [self::KEY_REF => $ids[$oid]];
+ }
+
+ $data = [];
+ if (($counts[$oid] ?? 1) > 1) {
+ $ids[$oid] = count($ids) + 1;
+ $data[self::KEY_ID] = $ids[$oid];
+ }
+ $emitted[$oid] = true;
+
+ $data['action'] = $changeSet->getAction();
+ $data['entity'] = $this->encodeId($changeSet->getIdentification());
+ $data['properties'] = [];
+
+ foreach ($changeSet->getChangedProperties() as $property) {
+ $data['properties'][$property->getName()] = $this->encodeProperty($property, $counts, $ids, $emitted);
+ }
+
+ return $data;
+ }
+
+ /**
+ * @param array $counts
+ * @param array $ids
+ * @param array $emitted
+ * @return array
+ */
+ private function encodeProperty(PropertyChangeSet $property, array $counts, array &$ids, array &$emitted): array
+ {
+ if ($property instanceof Scalar) {
+ return [
+ 'type' => self::TYPE_SCALAR,
+ 'old' => $this->valueSerializer->encode($property->getOld()),
+ 'new' => $this->valueSerializer->encode($property->getNew()),
+ ];
+ }
+
+ if ($property instanceof ToOne) {
+ return [
+ 'type' => self::TYPE_TO_ONE,
+ 'old' => $this->encodeId($property->getOld()),
+ 'new' => $this->encodeId($property->getNew()),
+ 'changeSet' => $property->getChangeSet() !== null
+ ? $this->encodeChangeSet($property->getChangeSet(), $counts, $ids, $emitted)
+ : null,
+ ];
+ }
+
+ if ($property instanceof ToMany) {
+ return [
+ 'type' => self::TYPE_TO_MANY,
+ 'added' => array_map(fn (Id $id) => $this->encodeId($id), array_values($property->getAdded())),
+ 'removed' => array_map(fn (Id $id) => $this->encodeId($id), array_values($property->getRemoved())),
+ 'changeSets' => array_map(
+ fn (ChangeSet $nested) => $this->encodeChangeSet($nested, $counts, $ids, $emitted),
+ array_values($property->getChangeSets())
+ ),
+ ];
+ }
+
+ throw new UnexpectedValueException('There is no encoder for a property change set of type "' . $property::class . '".');
+ }
+
+ /**
+ * @return array|null
+ */
+ private function encodeId(?Id $id): ?array
+ {
+ if ($id === null) {
+ return null;
+ }
+
+ return [
+ 'class' => $id->getClass(),
+ 'id' => $id->getId(),
+ 'identification' => $this->valueSerializer->encodeArray($id->getIdentification() ?: []),
+ ];
+ }
+
+ /**
+ * @param array $data
+ * @param array $references
+ */
+ private function decodeChangeSet(array $data, array &$references): ChangeSet
+ {
+ if (isset($data[self::KEY_REF])) {
+ $ref = $data[self::KEY_REF];
+ if (!isset($references[$ref])) {
+ throw new UnexpectedValueException("Change set reference \"{$ref}\" points to an unknown change set.");
+ }
+
+ return $references[$ref];
+ }
+
+ $changeSet = new ChangeSet();
+
+ // registered before the properties are decoded so that cycles resolve to this instance
+ if (isset($data[self::KEY_ID])) {
+ $references[$data[self::KEY_ID]] = $changeSet;
+ }
+
+ $changeSet->setAction($data['action'] ?? ChangeSet::ACTION_EDIT);
+ $changeSet->setIdentification($this->decodeId($data['entity'] ?? null));
+
+ foreach ($data['properties'] ?? [] as $name => $property) {
+ $changeSet->restoreProperty($this->decodeProperty((string) $name, $property, $references));
+ }
+
+ return $changeSet;
+ }
+
+ /**
+ * @param array $data
+ * @param array $references
+ */
+ private function decodeProperty(string $name, array $data, array &$references): PropertyChangeSet
+ {
+ switch ($data['type'] ?? null) {
+ case self::TYPE_SCALAR:
+ return new Scalar(
+ $name,
+ $this->valueSerializer->decode($data['old'] ?? null),
+ $this->valueSerializer->decode($data['new'] ?? null)
+ );
+
+ case self::TYPE_TO_ONE:
+ $toOne = new ToOne($name, $this->decodeId($data['old'] ?? null), $this->decodeId($data['new'] ?? null));
+ if (isset($data['changeSet'])) {
+ $toOne->restoreChangeSet($this->decodeChangeSet($data['changeSet'], $references));
+ }
+
+ return $toOne;
+
+ case self::TYPE_TO_MANY:
+ $toMany = new ToMany($name);
+ $toMany->restore(
+ array_map(fn (array $id) => $this->decodeId($id), $data['added'] ?? []),
+ array_map(fn (array $id) => $this->decodeId($id), $data['removed'] ?? []),
+ array_map(fn (array $nested) => $this->decodeChangeSet($nested, $references), $data['changeSets'] ?? [])
+ );
+
+ return $toMany;
+ }
+
+ throw new UnexpectedValueException('There is no decoder for a property change set of type "' . ($data['type'] ?? 'null') . '".');
+ }
+
+ /**
+ * @param array|null $data
+ */
+ private function decodeId(?array $data): ?Id
+ {
+ if ($data === null) {
+ return null;
+ }
+
+ return new Id(
+ $data['id'] ?? null,
+ $data['class'] ?? '',
+ $this->valueSerializer->decodeArray($data['identification'] ?? [])
+ );
+ }
+}
diff --git a/src/Serializer/Handlers/ArrayHandler.php b/src/Serializer/Handlers/ArrayHandler.php
new file mode 100644
index 0000000..94e1d3a
--- /dev/null
+++ b/src/Serializer/Handlers/ArrayHandler.php
@@ -0,0 +1,37 @@
+ $serializer->encodeArray($value)];
+ }
+
+ public function decode(array $data, ValueSerializer $serializer): array
+ {
+ $value = $data['value'] ?? [];
+
+ return is_array($value) ? $serializer->decodeArray($value) : [$value];
+ }
+}
diff --git a/src/Serializer/Handlers/BinaryHandler.php b/src/Serializer/Handlers/BinaryHandler.php
new file mode 100644
index 0000000..a19cbd3
--- /dev/null
+++ b/src/Serializer/Handlers/BinaryHandler.php
@@ -0,0 +1,38 @@
+ base64_encode($value)];
+ }
+
+ public function decode(array $data, ValueSerializer $serializer): string
+ {
+ return base64_decode((string) ($data['value'] ?? ''), true) ?: '';
+ }
+}
diff --git a/src/Serializer/Handlers/DateTimeHandler.php b/src/Serializer/Handlers/DateTimeHandler.php
new file mode 100644
index 0000000..9ab654f
--- /dev/null
+++ b/src/Serializer/Handlers/DateTimeHandler.php
@@ -0,0 +1,58 @@
+ $value::class,
+ 'value' => $value->format($value->format('u') === '000000' ? self::FORMAT : self::FORMAT_MICROSECONDS),
+ ];
+ }
+
+ public function decode(array $data, ValueSerializer $serializer): ?DateTimeInterface
+ {
+ if (!isset($data['value'])) {
+ return null;
+ }
+
+ $class = $data['class'] ?? DateTimeImmutable::class;
+ if (!is_a($class, DateTimeInterface::class, true)) {
+ $class = DateTimeImmutable::class;
+ }
+
+ try {
+ return new $class($data['value']);
+ } catch (Throwable) {
+ return new DateTimeImmutable($data['value']);
+ }
+ }
+}
diff --git a/src/Serializer/Handlers/EnumHandler.php b/src/Serializer/Handlers/EnumHandler.php
new file mode 100644
index 0000000..0d7daff
--- /dev/null
+++ b/src/Serializer/Handlers/EnumHandler.php
@@ -0,0 +1,58 @@
+ $value::class,
+ 'value' => $value instanceof BackedEnum ? $value->value : $value->name,
+ ];
+ }
+
+ public function decode(array $data, ValueSerializer $serializer): mixed
+ {
+ $value = $data['value'] ?? null;
+ $class = $data['class'] ?? null;
+
+ if ($value === null || !is_string($class) || !enum_exists($class)) {
+ return $value;
+ }
+
+ if (is_a($class, BackedEnum::class, true)) {
+ return $class::tryFrom($value) ?? $value;
+ }
+
+ foreach ($class::cases() as $case) {
+ if ($case->name === $value) {
+ return $case;
+ }
+ }
+
+ // the case was removed from the code, keep the raw value readable
+ return $value;
+ }
+}
diff --git a/src/Serializer/Handlers/FloatHandler.php b/src/Serializer/Handlers/FloatHandler.php
new file mode 100644
index 0000000..f8b6070
--- /dev/null
+++ b/src/Serializer/Handlers/FloatHandler.php
@@ -0,0 +1,46 @@
+ 'NAN'];
+ }
+
+ return ['value' => $value > 0 ? 'INF' : '-INF'];
+ }
+
+ public function decode(array $data, ValueSerializer $serializer): float
+ {
+ return match ($data['value'] ?? null) {
+ 'INF' => INF,
+ '-INF' => -INF,
+ default => NAN,
+ };
+ }
+}
diff --git a/src/Serializer/Handlers/ObjectHandler.php b/src/Serializer/Handlers/ObjectHandler.php
new file mode 100644
index 0000000..2aca50e
--- /dev/null
+++ b/src/Serializer/Handlers/ObjectHandler.php
@@ -0,0 +1,51 @@
+ $value::class, 'value' => (string) $value];
+ }
+
+ if ($value instanceof JsonSerializable) {
+ return ['class' => $value::class, 'value' => $serializer->encodeNested($value->jsonSerialize())];
+ }
+
+ return ['class' => $value::class, 'value' => null];
+ }
+
+ public function decode(array $data, ValueSerializer $serializer): mixed
+ {
+ return $serializer->decodeNested($data['value'] ?? null);
+ }
+}
diff --git a/src/Serializer/ValueHandler.php b/src/Serializer/ValueHandler.php
new file mode 100644
index 0000000..bbe1c9d
--- /dev/null
+++ b/src/Serializer/ValueHandler.php
@@ -0,0 +1,31 @@
+ envelope payload without the "@type" key
+ */
+ public function encode(mixed $value, ValueSerializer $serializer): array;
+
+ /**
+ * @param array $data full envelope including the "@type" key
+ */
+ public function decode(array $data, ValueSerializer $serializer): mixed;
+}
diff --git a/src/Serializer/ValueSerializer.php b/src/Serializer/ValueSerializer.php
new file mode 100644
index 0000000..500e1ee
--- /dev/null
+++ b/src/Serializer/ValueSerializer.php
@@ -0,0 +1,124 @@
+handlers = [
+ ...array_values($handlers),
+ new DateTimeHandler(),
+ new EnumHandler(),
+ new FloatHandler(),
+ new BinaryHandler(),
+ new ArrayHandler(),
+ new ObjectHandler(),
+ ];
+ }
+
+ public function encode(mixed $value): mixed
+ {
+ foreach ($this->handlers as $handler) {
+ if ($handler->supports($value)) {
+ return [self::TYPE_KEY => $handler->getType()] + $handler->encode($value, $this);
+ }
+ }
+
+ if ($value === null || is_scalar($value)) {
+ return $value;
+ }
+
+ throw new UnexpectedValueException('There is no value handler for a value of type "' . get_debug_type($value) . '".');
+ }
+
+ public function decode(mixed $value): mixed
+ {
+ if (!is_array($value)) {
+ return $value;
+ }
+
+ if (!isset($value[self::TYPE_KEY])) {
+ throw new UnexpectedValueException('A value position holds an array without the "' . self::TYPE_KEY . '" key.');
+ }
+
+ foreach ($this->handlers as $handler) {
+ if ($handler->getType() === $value[self::TYPE_KEY]) {
+ return $handler->decode($value, $this);
+ }
+ }
+
+ // the handler is gone, degrade to whatever is readable instead of breaking the whole log
+ return $value['value'] ?? null;
+ }
+
+ /**
+ * Encodes a value nested inside an array. Unlike encode(), arrays stay arrays
+ * so that the contents of a json column remain readable.
+ */
+ public function encodeNested(mixed $value): mixed
+ {
+ if (!is_array($value)) {
+ return $this->encode($value);
+ }
+
+ $encoded = $this->encodeArray($value);
+
+ // the array itself contains our marker key, escape it so decoding stays unambiguous
+ return isset($encoded[self::TYPE_KEY])
+ ? [self::TYPE_KEY => ArrayHandler::TYPE, 'value' => $encoded]
+ : $encoded;
+ }
+
+ public function decodeNested(mixed $value): mixed
+ {
+ if (!is_array($value)) {
+ return $value;
+ }
+
+ return isset($value[self::TYPE_KEY]) ? $this->decode($value) : $this->decodeArray($value);
+ }
+
+ /**
+ * @param array $values
+ * @return array
+ */
+ public function encodeArray(array $values): array
+ {
+ return array_map(fn ($value) => $this->encodeNested($value), $values);
+ }
+
+ /**
+ * @param array $values
+ * @return array
+ */
+ public function decodeArray(array $values): array
+ {
+ return array_map(fn ($value) => $this->decodeNested($value), $values);
+ }
+}
diff --git a/src/Service/ChangeSetFactory.php b/src/Service/ChangeSetFactory.php
index a676b02..1751106 100644
--- a/src/Service/ChangeSetFactory.php
+++ b/src/Service/ChangeSetFactory.php
@@ -2,10 +2,10 @@
namespace ADT\DoctrineLoggable\Service;
-use Adt\DoctrineLoggable\ChangeSet AS CS;
+use ADT\DoctrineLoggable\ChangeSet AS CS;
use ADT\DoctrineLoggable\Attributes AS DLA;
use ADT\DoctrineLoggable\ChangeSet\ChangeSet;
-use Adt\DoctrineLoggable\ChangeSet\ToMany;
+use ADT\DoctrineLoggable\ChangeSet\ToMany;
use ADT\DoctrineLoggable\Entity\ChangeLog;
use DateTimeInterface;
use Doctrine\Common\Collections\Collection;
@@ -117,16 +117,25 @@ public function getLoggableEntityAssociationStructure($className = null, $path =
}
$associationMapping = $classMetadata->getAssociationMapping($property->getName());
- $associationPropertyName = '';
+ $associationPropertyNames = [];
if ($associationMapping['type'] === ClassMetadata::ONE_TO_ONE) {
- $associationPropertyName = 'inversedBy';
+ // na vlastnicke strane drzi zpetnou vazbu inversedBy, na inverzni mappedBy
+ $associationPropertyNames = ['mappedBy', 'inversedBy'];
}
elseif ($associationMapping['type'] === ClassMetadata::ONE_TO_MANY){
- $associationPropertyName = 'mappedBy';
+ $associationPropertyNames = ['mappedBy'];
}
- if ($associationPropertyName) {
- if (!empty($associationMapping[$associationPropertyName])) {
- $structure[$associationMapping['targetEntity']][] = array_merge([$associationMapping[$associationPropertyName]], $path);
+ if ($associationPropertyNames) {
+ $backReference = null;
+ foreach ($associationPropertyNames as $associationPropertyName) {
+ if (!empty($associationMapping[$associationPropertyName])) {
+ $backReference = $associationMapping[$associationPropertyName];
+ break;
+ }
+ }
+
+ if ($backReference !== null) {
+ $structure[$associationMapping['targetEntity']][] = array_merge([$backReference], $path);
} else {
$structure[$associationMapping['targetEntity']][] = $classMetadata->getName(). '::' . $property->getName();
}
@@ -206,6 +215,26 @@ public function processLoggedEntity($entity, $relatedEntity = null): void
$this->updateLogEntry($entity, $changeSet);
}
+ /**
+ * Zahodi cache klicovane pres spl_object_hash.
+ *
+ * Po clear() jsou vsechny entity odpojene a uvolnene, jenze PHP hash uvolneneho objektu
+ * znovu pouzije pro nekterou z tech, ktere vzniknou po nem. Nova entita by pak dostala
+ * cizi identifikaci, cizi change set a hlavne cizi, uz odpojeny ChangeLog - a flush by
+ * spadl na "entity is not managed". V dlouho bezicim procesu, ktery flushuje a clearuje
+ * v cyklu, je to jinak jen otazka casu.
+ *
+ * Cache trid a struktury asociaci se nechavaji, ty jsou klicovane nazvem tridy.
+ */
+ public function onClear(): void
+ {
+ $this->logEntries = [];
+ $this->identifications = [];
+ $this->computedEntityChangeSets = [];
+ $this->changeSetsInProgress = [];
+ $this->scheduledEntities = [];
+ }
+
public function updateIdentification($entity): void
{
$oid = spl_object_hash($entity);
@@ -277,7 +306,8 @@ protected function getChangeSet(?object $entity = null, ?object $relatedEntity
private function collectPropertyChanges(object $entity, ChangeSet $changeSet, ?object $relatedEntity): void
{
$uowEntiyChangeSet = $this->uow->getEntityChangeSet($entity);
- foreach ($this->getLoggedProperties(get_class($entity)) as $property) {
+ // u proxy vraci get_class tridu proxy, ta ma vlastni properties s cizimi atributy
+ foreach ($this->getLoggedProperties(ClassUtils::getClass($entity)) as $property) {
// property is scalar
$columnAnnotation = $this->reader->getPropertyAttribute($property, Column::class);
if ($columnAnnotation) {
@@ -431,8 +461,11 @@ protected function getAssociationChangeSet($entity, ReflectionProperty $property
}
// inversed side - its OneToOne with mappedBy annotation
- // TODO poradne otestovat, nebo este lepsi udelat testy
} else {
+ // zmeny v navazane entite se logujou stejne jako u vlastnicke strany a u toMany,
+ // bez toho by property s LoggableProperty na inverzni strane nelogovala vubec nic
+ $changeSet = $this->getChangeSet($relatedEntity);
+
$ownerProperty = $oneToOneAnnotation->mappedBy;
$ownerClass = $this->em->getClassMetadata(ClassUtils::getClass($entity))
->getAssociationTargetClass($property->name);
@@ -490,6 +523,11 @@ public function createIdentification(?object $entity = NULL): ?CS\Id
$newValues = [];
foreach ($fieldNameParts as $fieldNamePart) {
foreach ($values as $value) {
+ // a nullable relation anywhere along the path ends it, there is nothing to read
+ if (!is_object($value)) {
+ continue;
+ }
+
if ($value instanceof Proxy) {
if (!$value->__isInitialized()) {
$value->__load();
@@ -539,7 +577,7 @@ protected function convertIdentificationValue($value)
/**
* @param $entityClassName
- * @return ReflectionProperty[]
+ * @return ReflectionProperty[] keyed by property name
* @throws ReflectionException
*/
protected function getLoggedProperties($entityClassName): array
@@ -550,7 +588,7 @@ protected function getLoggedProperties($entityClassName): array
foreach ($reflection->getProperties() as $property) {
$an = $this->reader->getPropertyAttribute($property, DLA\LoggableProperty::class);
if ($an !== NULL) {
- $list[] = $property;
+ $list[$property->getName()] = $property;
}
}
$this->loggableEntityProperties[$entityClassName] = $list;
@@ -598,6 +636,9 @@ public function updateLogEntry($entity, ChangeSet $changeSet): void
$this->logEntries[spl_object_hash($entity)] = $logEntry;
} else {
+ // the change set is mutated in place, so it stays the very same instance and Doctrine,
+ // which compares object valued fields by identity, would not see any change
+ $this->em->getUnitOfWork()->setOriginalEntityProperty(spl_object_id($logEntry), 'changeSet', null);
$this->em->getUnitOfWork()->recomputeSingleEntityChangeSet($this->em->getClassMetadata(get_class($logEntry)), $logEntry);
}
}
diff --git a/src/Service/LegacyChangeSetConversionResult.php b/src/Service/LegacyChangeSetConversionResult.php
new file mode 100644
index 0000000..08cacd0
--- /dev/null
+++ b/src/Service/LegacyChangeSetConversionResult.php
@@ -0,0 +1,63 @@
+ row id => error message */
+ private array $failures = [];
+
+ public function getConverted(): int
+ {
+ return $this->converted;
+ }
+
+ /**
+ * Rows that already held JSON, so a repeated run leaves them alone.
+ */
+ public function getSkipped(): int
+ {
+ return $this->skipped;
+ }
+
+ /**
+ * @return array
+ */
+ public function getFailures(): array
+ {
+ return $this->failures;
+ }
+
+ public function hasFailures(): bool
+ {
+ return $this->failures !== [];
+ }
+
+ public function addConverted(): void
+ {
+ $this->converted++;
+ }
+
+ public function addSkipped(): void
+ {
+ $this->skipped++;
+ }
+
+ public function addFailure(int $id, string $message): void
+ {
+ $this->failures[$id] = $message;
+ }
+
+ public function add(self $other): void
+ {
+ $this->converted += $other->converted;
+ $this->skipped += $other->skipped;
+ $this->failures += $other->failures;
+ }
+}
diff --git a/src/Service/LegacyChangeSetConverter.php b/src/Service/LegacyChangeSetConverter.php
new file mode 100644
index 0000000..0c288d9
--- /dev/null
+++ b/src/Service/LegacyChangeSetConverter.php
@@ -0,0 +1,212 @@
+connection = $connection;
+ $this->serializer = $serializer;
+ $this->table = $table;
+ }
+
+ public function countRows(): int
+ {
+ return (int) $this->connection->fetchOne('SELECT COUNT(*) FROM ' . $this->quotedTable());
+ }
+
+ /**
+ * @param callable(LegacyChangeSetConversionResult): void|null $onBatch called after every committed batch
+ */
+ public function convert(int $batchSize = 500, ?callable $onBatch = null, bool $dryRun = false): LegacyChangeSetConversionResult
+ {
+ if ($batchSize < 1) {
+ throw new UnexpectedValueException('The batch size has to be at least 1.');
+ }
+
+ $total = new LegacyChangeSetConversionResult();
+ $lastId = 0;
+
+ while (true) {
+ $rows = $this->connection->fetchAllAssociative(
+ 'SELECT id, change_set FROM ' . $this->quotedTable() . ' WHERE id > ? ORDER BY id ASC LIMIT ' . $batchSize,
+ [$lastId]
+ );
+
+ if (!$rows) {
+ break;
+ }
+
+ $batch = $this->convertBatch($rows, $dryRun);
+ $total->add($batch);
+ $lastId = (int) $rows[array_key_last($rows)]['id'];
+
+ if ($onBatch !== null) {
+ $onBatch($batch);
+ }
+ }
+
+ return $total;
+ }
+
+ /**
+ * @param array $rows
+ */
+ private function convertBatch(array $rows, bool $dryRun): LegacyChangeSetConversionResult
+ {
+ $result = new LegacyChangeSetConversionResult();
+
+ $this->connection->beginTransaction();
+
+ try {
+ foreach ($rows as $row) {
+ $id = (int) $row['id'];
+ $payload = $this->readPayload($row['change_set']);
+
+ if ($this->isAlreadyJson($payload)) {
+ $result->addSkipped();
+ continue;
+ }
+
+ try {
+ $json = $this->convertPayload($payload);
+ } catch (Throwable $e) {
+ $result->addFailure($id, $e->getMessage());
+ continue;
+ }
+
+ if (!$dryRun) {
+ $this->connection->executeStatement(
+ 'UPDATE ' . $this->quotedTable() . ' SET change_set = ? WHERE id = ?',
+ [$json, $id]
+ );
+ }
+
+ $result->addConverted();
+ }
+
+ if ($dryRun) {
+ $this->connection->rollBack();
+ } else {
+ $this->connection->commit();
+ }
+ } catch (Throwable $e) {
+ $this->connection->rollBack();
+ throw $e;
+ }
+
+ return $result;
+ }
+
+ public function convertPayload(string $payload): string
+ {
+ self::warmUpChangeSetClasses();
+
+ $changeSet = @unserialize($payload);
+
+ if (!$changeSet instanceof ChangeSet) {
+ throw new UnexpectedValueException('The payload does not unserialize into a ' . ChangeSet::class . '.');
+ }
+
+ // __sleep() left the property names out of the payload, __wakeup() used to put them back
+ $this->restoreNames($changeSet, []);
+
+ return json_encode(
+ $this->serializer->toArray($changeSet),
+ JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
+ );
+ }
+
+ /**
+ * Legacy payloads carry the original "Adt" spelling of the namespace. PHP resolves class names
+ * case insensitively, but only once the class is in the class table, and a composer classmap
+ * built as authoritative refuses to autoload a spelling it does not hold. Loading the classes
+ * up front makes unserialize() find them whatever the payload says.
+ */
+ private static function warmUpChangeSetClasses(): void
+ {
+ foreach ([ChangeSet::class, Id::class, Scalar::class, ToOne::class, ToMany::class] as $class) {
+ class_exists($class);
+ }
+ }
+
+ /**
+ * @param array $visited guards against the cycles the old format allowed
+ */
+ private function restoreNames(ChangeSet $changeSet, array $visited): void
+ {
+ $oid = spl_object_id($changeSet);
+ if (isset($visited[$oid])) {
+ return;
+ }
+ $visited[$oid] = true;
+
+ foreach ($changeSet->getChangedProperties() as $name => $property) {
+ $property->setName((string) $name);
+
+ if ($property instanceof ToOne) {
+ if ($property->getChangeSet() !== null) {
+ $this->restoreNames($property->getChangeSet(), $visited);
+ }
+ } elseif ($property instanceof ToMany) {
+ foreach ($property->getChangeSets() as $nested) {
+ $this->restoreNames($nested, $visited);
+ }
+ }
+ }
+ }
+
+ private function readPayload(mixed $value): string
+ {
+ if (is_resource($value)) {
+ $value = stream_get_contents($value);
+ }
+
+ return (string) $value;
+ }
+
+ /**
+ * A JSON payload always starts with "{", a serialize() payload never does. Deciding this in
+ * PHP rather than in SQL keeps the converter working on every platform.
+ */
+ private function isAlreadyJson(string $payload): bool
+ {
+ return str_starts_with(ltrim($payload), '{');
+ }
+
+ private function quotedTable(): string
+ {
+ return $this->connection->quoteSingleIdentifier($this->table);
+ }
+}
diff --git a/tests/Attributes/AttributeReaderTest.php b/tests/Attributes/AttributeReaderTest.php
new file mode 100644
index 0000000..35759e7
--- /dev/null
+++ b/tests/Attributes/AttributeReaderTest.php
@@ -0,0 +1,79 @@
+reader = new AttributeReader();
+ }
+
+ public function testClassAttributesAreKeyedByTheirClassName(): void
+ {
+ $attributes = $this->reader->getClassAttributes(new ReflectionClass(Article::class));
+
+ self::assertArrayHasKey(LoggableEntity::class, $attributes);
+ self::assertArrayHasKey(LoggableIdentification::class, $attributes);
+ }
+
+ public function testASingleClassAttributeIsReturnedAsAnInstance(): void
+ {
+ $attribute = $this->reader->getClassAttribute(new ReflectionClass(Author::class), LoggableIdentification::class);
+
+ self::assertInstanceOf(LoggableIdentification::class, $attribute);
+ self::assertSame(['name'], $attribute->fields);
+ }
+
+ public function testAMissingClassAttributeIsNull(): void
+ {
+ self::assertNull($this->reader->getClassAttribute(new ReflectionClass(Author::class), LoggableEntity::class));
+ }
+
+ public function testARepeatableAttributeIsReturnedAsAList(): void
+ {
+ $indexes = $this->reader->getClassAttribute(new ReflectionClass(\ADT\DoctrineLoggable\Entity\ChangeLog::class), Index::class);
+
+ self::assertIsArray($indexes);
+ self::assertContainsOnlyInstancesOf(Index::class, $indexes);
+ self::assertCount(3, $indexes);
+ }
+
+ public function testPropertyAttributesAreKeyedByTheirClassName(): void
+ {
+ $attributes = $this->reader->getPropertyAttributes(new ReflectionProperty(Article::class, 'title'));
+
+ self::assertArrayHasKey(Column::class, $attributes);
+ self::assertArrayHasKey(LoggableProperty::class, $attributes);
+ }
+
+ public function testASinglePropertyAttributeIsReturnedAsAnInstance(): void
+ {
+ $attribute = $this->reader->getPropertyAttribute(new ReflectionProperty(Article::class, 'author'), ManyToOne::class);
+
+ self::assertInstanceOf(ManyToOne::class, $attribute);
+ self::assertSame(Author::class, $attribute->targetEntity);
+ }
+
+ public function testAMissingPropertyAttributeIsNull(): void
+ {
+ self::assertNull($this->reader->getPropertyAttribute(new ReflectionProperty(Article::class, 'id'), LoggableProperty::class));
+ }
+}
diff --git a/tests/Attributes/LoggableIdentificationTest.php b/tests/Attributes/LoggableIdentificationTest.php
new file mode 100644
index 0000000..9753698
--- /dev/null
+++ b/tests/Attributes/LoggableIdentificationTest.php
@@ -0,0 +1,29 @@
+fields);
+ }
+
+ /**
+ * The annotation era spelling, #[LoggableIdentification(fields: [...])] lands here as well.
+ */
+ public function testAWrappedListOfFields(): void
+ {
+ self::assertSame(['name'], (new LoggableIdentification(['fields' => ['name']]))->fields);
+ }
+
+ public function testAnEmptyListIsAllowed(): void
+ {
+ self::assertSame([], (new LoggableIdentification([]))->fields);
+ }
+}
diff --git a/tests/ChangeSet/ChangeSetTest.php b/tests/ChangeSet/ChangeSetTest.php
new file mode 100644
index 0000000..f2c9dd3
--- /dev/null
+++ b/tests/ChangeSet/ChangeSetTest.php
@@ -0,0 +1,84 @@
+isChanged());
+ self::assertSame(ChangeSet::ACTION_EDIT, $changeSet->getAction());
+ self::assertSame([], $changeSet->getChangedProperties());
+ self::assertNull($changeSet->getIdentification());
+ }
+
+ public function testChangedPropertiesAreKeyedByName(): void
+ {
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange(new Scalar('title', 'Pivo', 'Pivo 12°'));
+
+ self::assertTrue($changeSet->isChanged());
+ self::assertSame(['title'], array_keys($changeSet->getChangedProperties()));
+ }
+
+ public function testUnchangedPropertiesAreDropped(): void
+ {
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange(new Scalar('title', 'Pivo', 'Pivo'));
+
+ self::assertFalse($changeSet->isChanged());
+ self::assertSame([], $changeSet->getChangedProperties());
+ }
+
+ public function testAddingTheSamePropertyTwiceMergesIt(): void
+ {
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange(new Scalar('title', 'Pivo', 'Pivo 12°'));
+ $changeSet->addPropertyChange(new Scalar('title', 'Pivo 12°', 'Pivo 11°'));
+
+ $title = $changeSet->getChangedProperties()['title'];
+ self::assertCount(1, $changeSet->getChangedProperties());
+ self::assertSame('Pivo', $title->getOld());
+ self::assertSame('Pivo 11°', $title->getNew());
+ }
+
+ public function testActionAndIdentificationAreFluent(): void
+ {
+ $identification = new Id('42', Article::class, ['title' => 'Pivo']);
+ $changeSet = new ChangeSet();
+
+ self::assertSame($changeSet, $changeSet->setAction(ChangeSet::ACTION_DELETE));
+ self::assertSame($changeSet, $changeSet->setIdentification($identification));
+ self::assertSame(ChangeSet::ACTION_DELETE, $changeSet->getAction());
+ self::assertSame($identification, $changeSet->getIdentification());
+ }
+
+ public function testRestorePropertyKeepsEvenAnUnchangedProperty(): void
+ {
+ $changeSet = new ChangeSet();
+ $toOne = new ToOne('author', null, null);
+ $changeSet->restoreProperty($toOne);
+
+ self::assertSame(['author' => $toOne], $changeSet->getChangedProperties());
+ }
+
+ public function testRestorePropertyOverwritesInsteadOfMerging(): void
+ {
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange(new Scalar('title', 'Pivo', 'Pivo 12°'));
+ $changeSet->restoreProperty(new Scalar('title', 'a', 'b'));
+
+ self::assertSame('a', $changeSet->getChangedProperties()['title']->getOld());
+ }
+}
diff --git a/tests/ChangeSet/IdTest.php b/tests/ChangeSet/IdTest.php
new file mode 100644
index 0000000..990ddd3
--- /dev/null
+++ b/tests/ChangeSet/IdTest.php
@@ -0,0 +1,32 @@
+ 'Franta']);
+
+ self::assertSame('42', $id->getId());
+ self::assertSame(Author::class, $id->getClass());
+ self::assertSame(['name' => 'Franta'], $id->getIdentification());
+ }
+
+ /**
+ * An entity persisted in this very flush has no id yet, postPersist fills it in later.
+ */
+ public function testTheIdCanBeFilledInAfterwards(): void
+ {
+ $id = new Id('', Author::class, []);
+ $id->setId('7');
+
+ self::assertSame('7', $id->getId());
+ }
+}
diff --git a/tests/ChangeSet/ScalarTest.php b/tests/ChangeSet/ScalarTest.php
new file mode 100644
index 0000000..d73759f
--- /dev/null
+++ b/tests/ChangeSet/ScalarTest.php
@@ -0,0 +1,60 @@
+getName());
+ self::assertSame('Pivo', $scalar->getOld());
+ self::assertSame('Pivo 12°', $scalar->getNew());
+ self::assertSame(PropertyChangeSet::TYPE_SCALAR, $scalar->getType());
+ }
+
+ public function testEqualValuesAreNotAChange(): void
+ {
+ self::assertFalse((new Scalar('title', 'Pivo', 'Pivo'))->isChanged());
+ self::assertFalse((new Scalar('rating', null, null))->isChanged());
+ }
+
+ public function testDifferentValuesAreAChange(): void
+ {
+ self::assertTrue((new Scalar('title', 'Pivo', 'Pivo 12°'))->isChanged());
+ self::assertTrue((new Scalar('rating', null, 5))->isChanged());
+ }
+
+ public function testMergeKeepsTheOriginalOldValue(): void
+ {
+ $scalar = new Scalar('title', 'Pivo', 'Pivo 12°');
+ $scalar->merge(new Scalar('title', 'Pivo 12°', 'Pivo 11°'));
+
+ self::assertSame('Pivo', $scalar->getOld());
+ self::assertSame('Pivo 11°', $scalar->getNew());
+ }
+
+ public function testMergingWithAnotherTypeIsRejected(): void
+ {
+ $this->expectException(UnexpectedValueException::class);
+
+ (new Scalar('title', 'a', 'b'))->merge(new ToOne('author'));
+ }
+
+ public function testNameCanBeChanged(): void
+ {
+ $scalar = new Scalar('title', 'a', 'b');
+ $scalar->setName('name');
+
+ self::assertSame('name', $scalar->getName());
+ }
+}
diff --git a/tests/ChangeSet/ToManyTest.php b/tests/ChangeSet/ToManyTest.php
new file mode 100644
index 0000000..678bffd
--- /dev/null
+++ b/tests/ChangeSet/ToManyTest.php
@@ -0,0 +1,153 @@
+isChanged());
+ self::assertSame('tags', $toMany->getName());
+ self::assertSame(PropertyChangeSet::TYPE_TO_MANY, $toMany->getType());
+ }
+
+ public function testAddedAndRemovedAreKeptApart(): void
+ {
+ $toMany = new ToMany('tags');
+ $toMany->addAdded($this->tag('9', 'akce'));
+ $toMany->addRemoved($this->tag('3', 'novinka'));
+
+ self::assertTrue($toMany->isChanged());
+ self::assertSame(['akce'], $this->names($toMany->getAdded()));
+ self::assertSame(['novinka'], $this->names($toMany->getRemoved()));
+ }
+
+ public function testAddingTheSameIdentificationTwiceKeepsItOnce(): void
+ {
+ $toMany = new ToMany('tags');
+ $toMany->addAdded($this->tag('9', 'akce'));
+ $toMany->addAdded($this->tag('9', 'akce'));
+
+ self::assertCount(1, $toMany->getAdded());
+ }
+
+ public function testRemovingSomethingThatWasJustAddedCancelsBothOut(): void
+ {
+ $toMany = new ToMany('tags');
+ $toMany->addAdded($this->tag('9', 'akce'));
+ $toMany->addRemoved($this->tag('9', 'akce'));
+
+ self::assertSame([], $toMany->getAdded());
+ self::assertSame([], $toMany->getRemoved());
+ self::assertFalse($toMany->isChanged());
+ }
+
+ public function testAddingSomethingThatWasJustRemovedCancelsBothOut(): void
+ {
+ $toMany = new ToMany('tags');
+ $toMany->addRemoved($this->tag('9', 'akce'));
+ $toMany->addAdded($this->tag('9', 'akce'));
+
+ self::assertSame([], $toMany->getAdded());
+ self::assertSame([], $toMany->getRemoved());
+ }
+
+ public function testAChangeSetOfAnItemIsAChangeOnItsOwn(): void
+ {
+ $nested = new ChangeSet();
+ $nested->addPropertyChange(new Scalar('name', 'akce', 'sleva'));
+
+ $toMany = new ToMany('tags');
+ $toMany->addChangeSet($nested);
+
+ self::assertTrue($toMany->isChanged());
+ self::assertSame([$nested], $toMany->getChangeSets());
+ }
+
+ public function testEmptyAndNullChangeSetsAreIgnored(): void
+ {
+ $toMany = new ToMany('tags');
+ $toMany->addChangeSet(new ChangeSet());
+ $toMany->addChangeSet(null);
+
+ self::assertSame([], $toMany->getChangeSets());
+ }
+
+ public function testMergeCombinesBothCollectionChanges(): void
+ {
+ $first = new ToMany('tags');
+ $first->addAdded($this->tag('9', 'akce'));
+
+ $second = new ToMany('tags');
+ $second->addAdded($this->tag('5', 'sleva'));
+ $second->addRemoved($this->tag('3', 'novinka'));
+
+ $first->merge($second);
+
+ self::assertSame(['akce', 'sleva'], $this->names($first->getAdded()));
+ self::assertSame(['novinka'], $this->names($first->getRemoved()));
+ }
+
+ public function testMergeCancelsOutWhatTheOtherSideRemoved(): void
+ {
+ $first = new ToMany('tags');
+ $first->addAdded($this->tag('9', 'akce'));
+
+ $second = new ToMany('tags');
+ $second->addRemoved($this->tag('9', 'akce'));
+
+ $first->merge($second);
+
+ self::assertSame([], $first->getAdded());
+ self::assertSame([], $first->getRemoved());
+ }
+
+ public function testRestoreAssignsEverythingWithoutAnyLookup(): void
+ {
+ $added = [$this->tag('9', 'akce')];
+ $removed = [$this->tag('3', 'novinka')];
+ $changeSets = [new ChangeSet()];
+
+ $toMany = new ToMany('tags');
+ $toMany->restore($added, $removed, $changeSets);
+
+ self::assertSame($added, $toMany->getAdded());
+ self::assertSame($removed, $toMany->getRemoved());
+ self::assertSame($changeSets, $toMany->getChangeSets());
+ }
+
+ public function testRestoreReindexesTheArrays(): void
+ {
+ $toMany = new ToMany('tags');
+ $toMany->restore([3 => $this->tag('9', 'akce')], [7 => $this->tag('3', 'novinka')], []);
+
+ self::assertSame([0], array_keys($toMany->getAdded()));
+ self::assertSame([0], array_keys($toMany->getRemoved()));
+ }
+
+ private function tag(string $id, string $name): Id
+ {
+ return new Id($id, Tag::class, ['name' => $name]);
+ }
+
+ /**
+ * @param Id[] $identifications
+ * @return string[]
+ */
+ private function names(array $identifications): array
+ {
+ return array_values(array_map(fn (Id $id) => $id->getIdentification()['name'], $identifications));
+ }
+}
diff --git a/tests/ChangeSet/ToOneTest.php b/tests/ChangeSet/ToOneTest.php
new file mode 100644
index 0000000..f32fbc9
--- /dev/null
+++ b/tests/ChangeSet/ToOneTest.php
@@ -0,0 +1,93 @@
+ 'Franta']);
+ $new = new Id('2', Author::class, ['name' => 'Pepa']);
+ $toOne = new ToOne('author', $old, $new);
+
+ self::assertSame('author', $toOne->getName());
+ self::assertSame($old, $toOne->getOld());
+ self::assertSame($new, $toOne->getNew());
+ self::assertNull($toOne->getChangeSet());
+ self::assertSame(PropertyChangeSet::TYPE_TO_ONE, $toOne->getType());
+ }
+
+ public function testTheSameIdentificationOnBothSidesIsNotAChange(): void
+ {
+ $identification = new Id('1', Author::class, []);
+
+ self::assertFalse((new ToOne('author', $identification, $identification))->isChanged());
+ self::assertFalse((new ToOne('author'))->isChanged());
+ }
+
+ public function testADifferentIdentificationIsAChange(): void
+ {
+ $toOne = new ToOne('author', new Id('1', Author::class, []), new Id('2', Author::class, []));
+
+ self::assertTrue($toOne->isChanged());
+ }
+
+ public function testANestedChangeSetAloneIsAChange(): void
+ {
+ $identification = new Id('1', Author::class, []);
+ $nested = new ChangeSet();
+ $nested->addPropertyChange(new Scalar('name', 'Franta', 'František'));
+
+ $toOne = new ToOne('author', $identification, $identification);
+ $toOne->setChangeSet($nested);
+
+ self::assertTrue($toOne->isChanged());
+ self::assertSame($nested, $toOne->getChangeSet());
+ }
+
+ public function testSetChangeSetDropsAnUnchangedOne(): void
+ {
+ $toOne = new ToOne('author');
+ $toOne->setChangeSet(new ChangeSet());
+
+ self::assertNull($toOne->getChangeSet());
+
+ $toOne->setChangeSet(null);
+ self::assertNull($toOne->getChangeSet());
+ }
+
+ public function testRestoreChangeSetKeepsAnEmptyOne(): void
+ {
+ $empty = new ChangeSet();
+ $toOne = new ToOne('author');
+ $toOne->restoreChangeSet($empty);
+
+ self::assertSame($empty, $toOne->getChangeSet());
+ }
+
+ public function testMergeTakesTheNewIdentificationAndChangeSet(): void
+ {
+ $first = new ToOne('author', new Id('1', Author::class, []), new Id('2', Author::class, []));
+
+ $nested = new ChangeSet();
+ $nested->addPropertyChange(new Scalar('name', 'a', 'b'));
+ $second = new ToOne('author', new Id('2', Author::class, []), new Id('3', Author::class, []));
+ $second->setChangeSet($nested);
+
+ $first->merge($second);
+
+ self::assertSame('1', $first->getOld()->getId());
+ self::assertSame('3', $first->getNew()->getId());
+ self::assertSame($nested, $first->getChangeSet());
+ }
+}
diff --git a/tests/Console/ConvertLegacyChangeSetsCommandTest.php b/tests/Console/ConvertLegacyChangeSetsCommandTest.php
new file mode 100644
index 0000000..e2ae2ff
--- /dev/null
+++ b/tests/Console/ConvertLegacyChangeSetsCommandTest.php
@@ -0,0 +1,109 @@
+connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
+ $this->connection->executeStatement('CREATE TABLE change_log (id INTEGER PRIMARY KEY AUTOINCREMENT, change_set BLOB NOT NULL)');
+
+ $this->tester = new CommandTester(
+ new ConvertLegacyChangeSetsCommand(new LegacyChangeSetConverter($this->connection, new ChangeSetSerializer()))
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ $this->connection->close();
+ }
+
+ public function testItConvertsEveryRowAndTellsYouToChangeTheColumn(): void
+ {
+ $this->insertLegacyRow();
+ $this->insertLegacyRow();
+
+ self::assertSame(Command::SUCCESS, $this->tester->execute([]));
+
+ $output = $this->tester->getDisplay();
+ self::assertStringContainsString('Converting 2 change_log rows', $output);
+ self::assertStringContainsString('2 converted, 0 already json, 0 failed', $output);
+ self::assertStringContainsString('ALTER TABLE change_log MODIFY change_set JSON NOT NULL', $output);
+ self::assertStringStartsWith('{', $this->readRawRow(1));
+ }
+
+ public function testDryRunReportsWithoutWritingAndWithoutTheAlterHint(): void
+ {
+ $this->insertLegacyRow();
+
+ self::assertSame(Command::SUCCESS, $this->tester->execute(['--dry-run' => true]));
+
+ $output = $this->tester->getDisplay();
+ self::assertStringContainsString('[dry run]', $output);
+ self::assertStringContainsString('1 converted', $output);
+ self::assertStringNotContainsString('ALTER TABLE', $output);
+ self::assertStringStartsWith('O:', $this->readRawRow(1));
+ }
+
+ public function testABrokenRowMakesTheCommandFail(): void
+ {
+ $this->insertLegacyRow();
+ $this->connection->executeStatement('INSERT INTO change_log (change_set) VALUES (?)', ['garbage']);
+
+ self::assertSame(Command::FAILURE, $this->tester->execute([]));
+
+ $output = $this->tester->getDisplay();
+ self::assertStringContainsString('1 converted, 0 already json, 1 failed', $output);
+ self::assertStringContainsString('row 2:', $output);
+ self::assertStringContainsString('cannot be changed to JSON yet', $output);
+ }
+
+ public function testBatchSizeIsPassedThrough(): void
+ {
+ for ($i = 0; $i < 3; $i++) {
+ $this->insertLegacyRow();
+ }
+
+ $this->tester->execute(['--batch-size' => '1']);
+
+ self::assertSame(3, substr_count($this->tester->getDisplay(), 'batch: 1 converted'));
+ }
+
+ public function testAnEmptyTableSucceeds(): void
+ {
+ self::assertSame(Command::SUCCESS, $this->tester->execute([]));
+ self::assertStringContainsString('0 converted', $this->tester->getDisplay());
+ }
+
+ private function insertLegacyRow(): void
+ {
+ $this->connection->executeStatement(
+ 'INSERT INTO change_log (change_set) VALUES (?)',
+ [LegacyPayloadBuilder::build((new ChangeSetStub())->addProperty('title', new ScalarStub('a', 'b')))]
+ );
+ }
+
+ private function readRawRow(int $id): string
+ {
+ return (string) $this->connection->fetchOne('SELECT change_set FROM change_log WHERE id = ?', [$id]);
+ }
+}
diff --git a/tests/DI/LoggableExtensionTest.php b/tests/DI/LoggableExtensionTest.php
new file mode 100644
index 0000000..c104ba1
--- /dev/null
+++ b/tests/DI/LoggableExtensionTest.php
@@ -0,0 +1,94 @@
+createContainer();
+
+ self::assertInstanceOf(ChangeSetFactory::class, $container->getByType(ChangeSetFactory::class));
+ self::assertInstanceOf(ChangeSetSerializer::class, $container->getByType(ChangeSetSerializer::class));
+ self::assertContains(
+ $container->getByType(LoggableListener::class),
+ $container->getByType(EventManager::class)->getListeners('onFlush')
+ );
+
+ self::assertTrue(Type::hasType(ChangeSetType::NAME));
+ self::assertInstanceOf(ChangeSetType::class, Type::getType(ChangeSetType::NAME));
+ }
+
+ public function testLegacyConverterAndItsCommandAreRegistered(): void
+ {
+ $container = $this->createContainer();
+
+ self::assertInstanceOf(LegacyChangeSetConverter::class, $container->getByType(LegacyChangeSetConverter::class));
+ self::assertInstanceOf(ConvertLegacyChangeSetsCommand::class, $container->getByType(ConvertLegacyChangeSetsCommand::class));
+ }
+
+ public function testConfiguredValueHandlersReachTheRegisteredType(): void
+ {
+ $this->createContainer(['valueHandlers' => [MoneyHandler::class]]);
+
+ $valueSerializer = Type::getType(ChangeSetType::NAME)->getSerializer()->getValueSerializer();
+ $encoded = $valueSerializer->encode(new Money(3900, 'CZK'));
+
+ self::assertSame(['@type' => 'money', 'amount' => 3900, 'currency' => 'CZK'], $encoded);
+ self::assertEquals(new Money(3900, 'CZK'), $valueSerializer->decode($encoded));
+ }
+
+ /**
+ * @param array $extensionConfig
+ */
+ private function createContainer(array $extensionConfig = []): Container
+ {
+ $config = [
+ 'services' => [
+ 'eventManager' => EventManager::class,
+ 'connection' => new Statement([TestConnectionFactory::class, 'create']),
+ 'user' => FakeUser::class,
+ ],
+ ];
+
+ if ($extensionConfig) {
+ $config['doctrineLoggable'] = $extensionConfig;
+ }
+
+ $loader = new ContainerLoader(sys_get_temp_dir() . '/doctrine-loggable-di', true);
+ $class = $loader->load(
+ function (Compiler $compiler) use ($config): void {
+ $compiler->addExtension('doctrineLoggable', new LoggableExtension());
+ $compiler->addConfig($config);
+ },
+ json_encode($extensionConfig)
+ );
+
+ $container = new $class();
+ // Nette\Bootstrap\Configurator does this for a real application
+ $container->initialize();
+
+ return $container;
+ }
+}
diff --git a/tests/Doctrine/ChangeSetTypeTest.php b/tests/Doctrine/ChangeSetTypeTest.php
new file mode 100644
index 0000000..5acf13c
--- /dev/null
+++ b/tests/Doctrine/ChangeSetTypeTest.php
@@ -0,0 +1,94 @@
+type = new ChangeSetType();
+ $this->platform = new SQLitePlatform();
+ }
+
+ public function testNullPassesThrough(): void
+ {
+ self::assertNull($this->type->convertToDatabaseValue(null, $this->platform));
+ self::assertNull($this->type->convertToPHPValue(null, $this->platform));
+ self::assertNull($this->type->convertToPHPValue('', $this->platform));
+ }
+
+ public function testChangeSetIsStoredAsJsonAndReadBack(): void
+ {
+ $changeSet = new ChangeSet();
+ $changeSet->setIdentification(new Id('42', Article::class, ['title' => 'Pivo']));
+ $changeSet->addPropertyChange(new Scalar('title', 'Pivo', 'Pivo 12°'));
+
+ $stored = $this->type->convertToDatabaseValue($changeSet, $this->platform);
+
+ self::assertJson($stored);
+ self::assertStringContainsString('"title"', $stored);
+
+ $decoded = $this->type->convertToPHPValue($stored, $this->platform);
+ self::assertSame('Pivo 12°', $decoded->getChangedProperties()['title']->getNew());
+ }
+
+ public function testStreamResourceIsSupported(): void
+ {
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange(new Scalar('title', 'a', 'b'));
+
+ $stream = fopen('php://memory', 'r+');
+ fwrite($stream, $this->type->convertToDatabaseValue($changeSet, $this->platform));
+ rewind($stream);
+
+ $decoded = $this->type->convertToPHPValue($stream, $this->platform);
+ fclose($stream);
+
+ self::assertSame('b', $decoded->getChangedProperties()['title']->getNew());
+ }
+
+ public function testNonChangeSetValueIsRejected(): void
+ {
+ $this->expectException(InvalidType::class);
+
+ $this->type->convertToDatabaseValue('nope', $this->platform);
+ }
+
+ public function testBrokenJsonIsRejected(): void
+ {
+ $this->expectException(ValueNotConvertible::class);
+
+ $this->type->convertToPHPValue('{not json', $this->platform);
+ }
+
+ public function testScalarJsonIsRejected(): void
+ {
+ $this->expectException(ValueNotConvertible::class);
+
+ $this->type->convertToPHPValue('42', $this->platform);
+ }
+
+ public function testRegisteringTwiceOverridesTheType(): void
+ {
+ ChangeSetType::register();
+ ChangeSetType::register();
+
+ self::assertInstanceOf(ChangeSetType::class, \Doctrine\DBAL\Types\Type::getType(ChangeSetType::NAME));
+ }
+}
diff --git a/tests/Entity/ChangeLogTest.php b/tests/Entity/ChangeLogTest.php
new file mode 100644
index 0000000..8d57a8e
--- /dev/null
+++ b/tests/Entity/ChangeLogTest.php
@@ -0,0 +1,62 @@
+getCreatedAt());
+ }
+
+ public function testEverythingItIsGivenComesBack(): void
+ {
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange(new Scalar('title', 'Pivo', 'Pivo 12°'));
+
+ $log = new ChangeLog();
+ $log->setAction(ChangeSet::ACTION_EDIT);
+ $log->setObjectClass(Article::class);
+ $log->setObjectId(42);
+ $log->setChangeSet($changeSet);
+ $log->setIdentityClass('App\\Model\\Entities\\Identity');
+ $log->setIdentityId(7);
+
+ self::assertSame(ChangeSet::ACTION_EDIT, $log->getAction());
+ self::assertSame(Article::class, $log->getObjectClass());
+ self::assertSame(42, $log->getObjectId());
+ self::assertSame($changeSet, $log->getChangeSet());
+ self::assertSame('App\\Model\\Entities\\Identity', $log->getIdentityClass());
+ self::assertSame(7, $log->getIdentityId());
+ }
+
+ public function testAGuestLeavesNoIdentity(): void
+ {
+ $log = new ChangeLog();
+ $log->setIdentityClass(null);
+ $log->setIdentityId(null);
+
+ self::assertNull($log->getIdentityClass());
+ self::assertNull($log->getIdentityId());
+ }
+
+ public function testAnObjectIdIsOptional(): void
+ {
+ $log = new ChangeLog();
+ $log->setObjectId(null);
+
+ self::assertNull($log->getObjectId());
+ }
+}
diff --git a/tests/Fixtures/Entity/Article.php b/tests/Fixtures/Entity/Article.php
new file mode 100644
index 0000000..265e20c
--- /dev/null
+++ b/tests/Fixtures/Entity/Article.php
@@ -0,0 +1,185 @@
+ */
+ #[ORM\ManyToMany(targetEntity: Tag::class)]
+ #[ADA\LoggableProperty]
+ private Collection $tags;
+
+ #[ORM\OneToOne(targetEntity: Cover::class, mappedBy: 'article')]
+ #[ADA\LoggableProperty]
+ private ?Cover $cover = null;
+
+ /** @var Collection */
+ #[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'article')]
+ #[ADA\LoggableProperty]
+ private Collection $comments;
+
+ /** @var Collection */
+ #[ORM\OneToMany(targetEntity: Attachment::class, mappedBy: 'owner')]
+ #[ADA\LoggableProperty]
+ private Collection $attachments;
+
+ public function __construct(string $title)
+ {
+ $this->title = $title;
+ $this->tags = new ArrayCollection();
+ $this->comments = new ArrayCollection();
+ $this->attachments = new ArrayCollection();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getTitle(): string
+ {
+ return $this->title;
+ }
+
+ public function setTitle(string $title): void
+ {
+ $this->title = $title;
+ }
+
+ public function setRating(?int $rating): void
+ {
+ $this->rating = $rating;
+ }
+
+ public function setPublishedAt(?DateTimeImmutable $publishedAt): void
+ {
+ $this->publishedAt = $publishedAt;
+ }
+
+ public function setState(?ArticleStateEnum $state): void
+ {
+ $this->state = $state;
+ }
+
+ public function setMeta(?array $meta): void
+ {
+ $this->meta = $meta;
+ }
+
+ public function getAuthor(): ?Author
+ {
+ return $this->author;
+ }
+
+ public function setAuthor(?Author $author): void
+ {
+ $this->author = $author;
+ }
+
+ /** @return Collection */
+ public function getTags(): Collection
+ {
+ return $this->tags;
+ }
+
+ public function addTag(Tag $tag): void
+ {
+ $this->tags->add($tag);
+ }
+
+ public function removeTag(Tag $tag): void
+ {
+ $this->tags->removeElement($tag);
+ }
+
+ /** Assigns a brand new collection instead of mutating the managed one. */
+ public function replaceTags(Tag ...$tags): void
+ {
+ $this->tags = new ArrayCollection($tags);
+ }
+
+ public function getCover(): ?Cover
+ {
+ return $this->cover;
+ }
+
+ public function setCover(?Cover $cover): void
+ {
+ $this->cover = $cover;
+
+ if ($cover !== null) {
+ $cover->setArticle($this);
+ }
+ }
+
+ /** @return Collection */
+ public function getComments(): Collection
+ {
+ return $this->comments;
+ }
+
+ public function addComment(Comment $comment): void
+ {
+ $this->comments->add($comment);
+ $comment->setArticle($this);
+ }
+
+ public function removeComment(Comment $comment): void
+ {
+ $this->comments->removeElement($comment);
+ $comment->setArticle(null);
+ }
+
+ /** @return Collection */
+ public function getAttachments(): Collection
+ {
+ return $this->attachments;
+ }
+
+ public function addAttachment(Attachment $attachment): void
+ {
+ $this->attachments->add($attachment);
+ $attachment->setOwner($this);
+ }
+}
diff --git a/tests/Fixtures/Entity/ArticleStateEnum.php b/tests/Fixtures/Entity/ArticleStateEnum.php
new file mode 100644
index 0000000..1fcbfda
--- /dev/null
+++ b/tests/Fixtures/Entity/ArticleStateEnum.php
@@ -0,0 +1,11 @@
+fileName = $fileName;
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getFileName(): string
+ {
+ return $this->fileName;
+ }
+
+ public function setFileName(string $fileName): void
+ {
+ $this->fileName = $fileName;
+ }
+
+ public function getOwner(): ?Article
+ {
+ return $this->owner;
+ }
+
+ public function setOwner(?Article $owner): void
+ {
+ $this->owner = $owner;
+ }
+}
diff --git a/tests/Fixtures/Entity/Author.php b/tests/Fixtures/Entity/Author.php
new file mode 100644
index 0000000..42ce56b
--- /dev/null
+++ b/tests/Fixtures/Entity/Author.php
@@ -0,0 +1,41 @@
+name = $name;
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function setName(string $name): void
+ {
+ $this->name = $name;
+ }
+}
diff --git a/tests/Fixtures/Entity/Chapter.php b/tests/Fixtures/Entity/Chapter.php
new file mode 100644
index 0000000..b2a76cc
--- /dev/null
+++ b/tests/Fixtures/Entity/Chapter.php
@@ -0,0 +1,59 @@
+title = $title;
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getTitle(): string
+ {
+ return $this->title;
+ }
+
+ public function setTitle(string $title): void
+ {
+ $this->title = $title;
+ }
+
+ public function getSeries(): ?Series
+ {
+ return $this->series;
+ }
+
+ public function setSeries(?Series $series): void
+ {
+ $this->series = $series;
+ }
+}
diff --git a/tests/Fixtures/Entity/Comment.php b/tests/Fixtures/Entity/Comment.php
new file mode 100644
index 0000000..af74065
--- /dev/null
+++ b/tests/Fixtures/Entity/Comment.php
@@ -0,0 +1,75 @@
+text = $text;
+ $this->author = $author;
+ $this->createdAt = new DateTimeImmutable('2026-08-27 12:00:00');
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getText(): string
+ {
+ return $this->text;
+ }
+
+ public function setText(string $text): void
+ {
+ $this->text = $text;
+ }
+
+ public function getCreatedAt(): DateTimeImmutable
+ {
+ return $this->createdAt;
+ }
+
+ public function getAuthor(): ?Author
+ {
+ return $this->author;
+ }
+
+ public function getArticle(): ?Article
+ {
+ return $this->article;
+ }
+
+ public function setArticle(?Article $article): void
+ {
+ $this->article = $article;
+ }
+}
diff --git a/tests/Fixtures/Entity/Cover.php b/tests/Fixtures/Entity/Cover.php
new file mode 100644
index 0000000..0b52257
--- /dev/null
+++ b/tests/Fixtures/Entity/Cover.php
@@ -0,0 +1,58 @@
+fileName = $fileName;
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getFileName(): string
+ {
+ return $this->fileName;
+ }
+
+ public function setFileName(string $fileName): void
+ {
+ $this->fileName = $fileName;
+ }
+
+ public function getArticle(): ?Article
+ {
+ return $this->article;
+ }
+
+ public function setArticle(?Article $article): void
+ {
+ $this->article = $article;
+ }
+}
diff --git a/tests/Fixtures/Entity/Event.php b/tests/Fixtures/Entity/Event.php
new file mode 100644
index 0000000..8ffa34c
--- /dev/null
+++ b/tests/Fixtures/Entity/Event.php
@@ -0,0 +1,59 @@
+ */
+ #[ORM\ManyToMany(targetEntity: Tag::class)]
+ private Collection $tags;
+
+ public function __construct()
+ {
+ $this->startsAt = new DateTimeImmutable('2026-08-27 14:30:00');
+ $this->wholeDayAt = new DateTimeImmutable('2026-08-27 00:00:00');
+ $this->tags = new ArrayCollection();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ /** @return Collection */
+ public function getTags(): Collection
+ {
+ return $this->tags;
+ }
+
+ public function addTag(Tag $tag): void
+ {
+ $this->tags->add($tag);
+ }
+}
diff --git a/tests/Fixtures/Entity/Issue.php b/tests/Fixtures/Entity/Issue.php
new file mode 100644
index 0000000..89e1033
--- /dev/null
+++ b/tests/Fixtures/Entity/Issue.php
@@ -0,0 +1,59 @@
+title = $title;
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getTitle(): string
+ {
+ return $this->title;
+ }
+
+ public function setTitle(string $title): void
+ {
+ $this->title = $title;
+ }
+
+ public function getSeries(): ?Series
+ {
+ return $this->series;
+ }
+
+ public function setSeries(?Series $series): void
+ {
+ $this->series = $series;
+ }
+}
diff --git a/tests/Fixtures/Entity/Logo.php b/tests/Fixtures/Entity/Logo.php
new file mode 100644
index 0000000..01cfc97
--- /dev/null
+++ b/tests/Fixtures/Entity/Logo.php
@@ -0,0 +1,46 @@
+fileName = $fileName;
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getFileName(): string
+ {
+ return $this->fileName;
+ }
+
+ public function setFileName(string $fileName): void
+ {
+ $this->fileName = $fileName;
+ }
+}
diff --git a/tests/Fixtures/Entity/Poll.php b/tests/Fixtures/Entity/Poll.php
new file mode 100644
index 0000000..29e9ed8
--- /dev/null
+++ b/tests/Fixtures/Entity/Poll.php
@@ -0,0 +1,46 @@
+code = $code;
+ $this->question = $question;
+ }
+
+ public function getId(): string
+ {
+ return $this->code;
+ }
+
+ public function getQuestion(): string
+ {
+ return $this->question;
+ }
+
+ public function setQuestion(string $question): void
+ {
+ $this->question = $question;
+ }
+}
diff --git a/tests/Fixtures/Entity/Series.php b/tests/Fixtures/Entity/Series.php
new file mode 100644
index 0000000..41e4da1
--- /dev/null
+++ b/tests/Fixtures/Entity/Series.php
@@ -0,0 +1,99 @@
+ */
+ #[ORM\OneToMany(targetEntity: Chapter::class, mappedBy: 'series')]
+ #[ADA\LoggableProperty]
+ private Collection $chapters;
+
+ public function __construct(string $name)
+ {
+ $this->name = $name;
+ $this->chapters = new ArrayCollection();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function setName(string $name): void
+ {
+ $this->name = $name;
+ }
+
+ public function getLatestIssue(): ?Issue
+ {
+ return $this->latestIssue;
+ }
+
+ public function setLatestIssue(?Issue $latestIssue): void
+ {
+ $this->latestIssue = $latestIssue;
+
+ if ($latestIssue !== null) {
+ $latestIssue->setSeries($this);
+ }
+ }
+
+ public function getLogo(): ?Logo
+ {
+ return $this->logo;
+ }
+
+ public function setLogo(?Logo $logo): void
+ {
+ $this->logo = $logo;
+ }
+
+ /** @return Collection */
+ public function getChapters(): Collection
+ {
+ return $this->chapters;
+ }
+
+ public function addChapter(Chapter $chapter): void
+ {
+ $this->chapters->add($chapter);
+ $chapter->setSeries($this);
+ }
+}
diff --git a/tests/Fixtures/Entity/Tag.php b/tests/Fixtures/Entity/Tag.php
new file mode 100644
index 0000000..2402160
--- /dev/null
+++ b/tests/Fixtures/Entity/Tag.php
@@ -0,0 +1,36 @@
+name = $name;
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+}
diff --git a/tests/Fixtures/EntityManagerFactory.php b/tests/Fixtures/EntityManagerFactory.php
new file mode 100644
index 0000000..222e7e5
--- /dev/null
+++ b/tests/Fixtures/EntityManagerFactory.php
@@ -0,0 +1,56 @@
+setNamingStrategy(new UnderscoreNamingStrategy());
+
+ $eventManager = new EventManager();
+ $eventManager->addEventSubscriber(new LoggableListener(new ChangeSetFactory($user ?? new FakeUser())));
+
+ $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true], $config);
+ $em = new EntityManager($connection, $config, $eventManager);
+
+ $schemaTool = new SchemaTool($em);
+ $schemaTool->createSchema($em->getMetadataFactory()->getAllMetadata());
+
+ return $em;
+ }
+
+ public static function findChangeLogs(EntityManagerInterface $em): array
+ {
+ return $em->getRepository(ChangeLog::class)->findBy([], ['id' => 'ASC']);
+ }
+}
diff --git a/tests/Fixtures/FakeUser.php b/tests/Fixtures/FakeUser.php
new file mode 100644
index 0000000..d6d86df
--- /dev/null
+++ b/tests/Fixtures/FakeUser.php
@@ -0,0 +1,49 @@
+identity = $identity;
+ }
+
+ public function clearAuthentication(bool $clearIdentity): void
+ {
+ $this->identity = null;
+ }
+
+ public function getState(): array
+ {
+ return [$this->identity !== null, $this->identity, null];
+ }
+
+ public function setExpiration(?string $expire, bool $clearIdentity): void
+ {
+ }
+ });
+ }
+
+ public static function withIdentity(int $id): self
+ {
+ return new self(new SimpleIdentity($id));
+ }
+}
diff --git a/tests/Fixtures/Legacy/ChangeSetStub.php b/tests/Fixtures/Legacy/ChangeSetStub.php
new file mode 100644
index 0000000..a70a6fd
--- /dev/null
+++ b/tests/Fixtures/Legacy/ChangeSetStub.php
@@ -0,0 +1,36 @@
+ keyed by property name, that is how __wakeup() restored them */
+ protected array $p = [];
+
+ public function setAction(string $action): static
+ {
+ $this->a = $action;
+
+ return $this;
+ }
+
+ public function setIdentification(?IdStub $identification): static
+ {
+ $this->i = $identification;
+
+ return $this;
+ }
+
+ public function addProperty(string $name, object $property): static
+ {
+ $this->p[$name] = $property;
+
+ return $this;
+ }
+}
diff --git a/tests/Fixtures/Legacy/IdStub.php b/tests/Fixtures/Legacy/IdStub.php
new file mode 100644
index 0000000..d9e09df
--- /dev/null
+++ b/tests/Fixtures/Legacy/IdStub.php
@@ -0,0 +1,21 @@
+id = $id;
+ $this->c = $class;
+ $this->d = $identificationData;
+ }
+}
diff --git a/tests/Fixtures/Legacy/LegacyPayloadBuilder.php b/tests/Fixtures/Legacy/LegacyPayloadBuilder.php
new file mode 100644
index 0000000..55a8abf
--- /dev/null
+++ b/tests/Fixtures/Legacy/LegacyPayloadBuilder.php
@@ -0,0 +1,36 @@
+ 'Adt\\DoctrineLoggable\\ChangeSet\\ChangeSet',
+ IdStub::class => 'Adt\\DoctrineLoggable\\ChangeSet\\Id',
+ ScalarStub::class => 'Adt\\DoctrineLoggable\\ChangeSet\\Scalar',
+ ToOneStub::class => 'Adt\\DoctrineLoggable\\ChangeSet\\ToOne',
+ ToManyStub::class => 'Adt\\DoctrineLoggable\\ChangeSet\\ToMany',
+ ];
+
+ public static function build(ChangeSetStub $changeSet): string
+ {
+ return preg_replace_callback(
+ '~O:\d+:"([^"]+)"~',
+ static function (array $match): string {
+ $class = self::CLASS_MAP[$match[1]] ?? $match[1];
+
+ return 'O:' . strlen($class) . ':"' . $class . '"';
+ },
+ serialize($changeSet)
+ );
+ }
+}
diff --git a/tests/Fixtures/Legacy/ScalarStub.php b/tests/Fixtures/Legacy/ScalarStub.php
new file mode 100644
index 0000000..b027763
--- /dev/null
+++ b/tests/Fixtures/Legacy/ScalarStub.php
@@ -0,0 +1,18 @@
+o = $old;
+ $this->n = $new;
+ }
+}
diff --git a/tests/Fixtures/Legacy/ToManyStub.php b/tests/Fixtures/Legacy/ToManyStub.php
new file mode 100644
index 0000000..936e631
--- /dev/null
+++ b/tests/Fixtures/Legacy/ToManyStub.php
@@ -0,0 +1,26 @@
+r = $removed;
+ $this->a = $added;
+ $this->ch = $changeSets;
+ }
+}
diff --git a/tests/Fixtures/Legacy/ToOneStub.php b/tests/Fixtures/Legacy/ToOneStub.php
new file mode 100644
index 0000000..c02ce78
--- /dev/null
+++ b/tests/Fixtures/Legacy/ToOneStub.php
@@ -0,0 +1,21 @@
+o = $old;
+ $this->n = $new;
+ $this->ch = $changeSet;
+ }
+}
diff --git a/tests/Fixtures/Money.php b/tests/Fixtures/Money.php
new file mode 100644
index 0000000..549d00b
--- /dev/null
+++ b/tests/Fixtures/Money.php
@@ -0,0 +1,12 @@
+ $value->amount, 'currency' => $value->currency];
+ }
+
+ public function decode(array $data, ValueSerializer $serializer): Money
+ {
+ return new Money($data['amount'], $data['currency']);
+ }
+}
diff --git a/tests/Fixtures/TestConnectionFactory.php b/tests/Fixtures/TestConnectionFactory.php
new file mode 100644
index 0000000..4f556fa
--- /dev/null
+++ b/tests/Fixtures/TestConnectionFactory.php
@@ -0,0 +1,16 @@
+ 'pdo_sqlite', 'memory' => true]);
+ }
+}
diff --git a/tests/Listener/LoggableListenerTest.php b/tests/Listener/LoggableListenerTest.php
new file mode 100644
index 0000000..3c79f23
--- /dev/null
+++ b/tests/Listener/LoggableListenerTest.php
@@ -0,0 +1,799 @@
+em = EntityManagerFactory::create();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->em->getConnection()->close();
+ }
+
+ public function testInsertIsNotLogged(): void
+ {
+ $this->em->persist(new Article('Pivo'));
+ $this->em->flush();
+
+ self::assertSame([], EntityManagerFactory::findChangeLogs($this->em));
+ }
+
+ public function testScalarChangeIsStoredAsReadableJson(): void
+ {
+ $article = new Article('Pivo');
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $article->setTitle('Pivo 12°');
+ $article->setRating(5);
+ $this->em->flush();
+
+ $json = $this->fetchRawChangeSet(1);
+
+ self::assertStringContainsString('"title"', $json);
+ self::assertStringContainsString('Pivo 12°', $json);
+ self::assertStringNotContainsString('O:', $json);
+
+ $logs = EntityManagerFactory::findChangeLogs($this->em);
+ self::assertCount(1, $logs);
+ self::assertSame(Article::class, $logs[0]->getObjectClass());
+ self::assertSame($article->getId(), $logs[0]->getObjectId());
+ self::assertSame(ChangeSet::ACTION_EDIT, $logs[0]->getAction());
+
+ $properties = $logs[0]->getChangeSet()->getChangedProperties();
+ self::assertInstanceOf(Scalar::class, $properties['title']);
+ self::assertSame('Pivo', $properties['title']->getOld());
+ self::assertSame('Pivo 12°', $properties['title']->getNew());
+ self::assertNull($properties['rating']->getOld());
+ self::assertSame(5, $properties['rating']->getNew());
+ }
+
+ public function testDateTimeEnumAndJsonColumnsSurviveTheRoundTrip(): void
+ {
+ $article = new Article('Pivo');
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $publishedAt = new DateTimeImmutable('2026-08-27 12:00:00');
+ $article->setPublishedAt($publishedAt);
+ $article->setState(ArticleStateEnum::Published);
+ $article->setMeta(['vat' => 21, 'tags' => ['akce']]);
+ $this->em->flush();
+
+ $this->em->clear();
+ $properties = $this->reloadFirstChangeLog()->getChangeSet()->getChangedProperties();
+
+ self::assertSame($publishedAt->format('c'), $properties['publishedAt']->getNew()->format('c'));
+ self::assertSame(['vat' => 21, 'tags' => ['akce']], $properties['meta']->getNew());
+
+ // Doctrine hands backed enums to the unit of work already converted to their value
+ self::assertSame(ArticleStateEnum::Published->value, $properties['state']->getNew());
+ }
+
+ public function testToOneChangeIsLoggedWithIdentification(): void
+ {
+ $franta = new Author('Franta');
+ $pepa = new Author('Pepa');
+ $article = new Article('Pivo');
+ $article->setAuthor($franta);
+ $this->em->persist($franta);
+ $this->em->persist($pepa);
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $article->setAuthor($pepa);
+ $this->em->flush();
+
+ $this->em->clear();
+ $author = $this->reloadFirstChangeLog()->getChangeSet()->getChangedProperties()['author'];
+
+ self::assertInstanceOf(ToOne::class, $author);
+ self::assertSame(['name' => 'Franta'], $author->getOld()->getIdentification());
+ self::assertSame(['name' => 'Pepa'], $author->getNew()->getIdentification());
+ self::assertSame(Author::class, $author->getNew()->getClass());
+ }
+
+ public function testToManyChangeIsLoggedWithAddedAndRemovedIdentifications(): void
+ {
+ $akce = new Tag('akce');
+ $novinka = new Tag('novinka');
+ $article = new Article('Pivo');
+ $article->addTag($novinka);
+ $this->em->persist($akce);
+ $this->em->persist($novinka);
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $article->removeTag($novinka);
+ $article->addTag($akce);
+ $this->em->flush();
+
+ $this->em->clear();
+ $tags = $this->reloadFirstChangeLog()->getChangeSet()->getChangedProperties()['tags'];
+
+ self::assertInstanceOf(ToMany::class, $tags);
+ self::assertSame(['akce'], array_map(fn ($id) => $id->getIdentification()['name'], array_values($tags->getAdded())));
+ self::assertSame(['novinka'], array_map(fn ($id) => $id->getIdentification()['name'], array_values($tags->getRemoved())));
+ }
+
+ public function testTwoChangesOfTheSameEntityInOneFlushEndUpInASingleRow(): void
+ {
+ $article = new Article('Pivo');
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $article->setTitle('Pivo 12°');
+ $article->setRating(5);
+ $this->em->flush();
+
+ $this->em->clear();
+ self::assertCount(1, EntityManagerFactory::findChangeLogs($this->em));
+ }
+
+ /**
+ * Regrese: the change set is mutated in place, so the row is only updated if the field is
+ * forced dirty. Doctrine compares object valued fields by identity and would see no change.
+ */
+ public function testRepeatedFlushesOfTheSameEntityKeepUpdatingTheSameRow(): void
+ {
+ $article = new Article('Pivo');
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $article->setTitle('Pivo 12°');
+ $this->em->flush();
+
+ $article->setTitle('Pivo 11°');
+ $article->setRating(5);
+ $this->em->flush();
+
+ $this->em->clear();
+ $logs = EntityManagerFactory::findChangeLogs($this->em);
+
+ self::assertCount(1, $logs);
+
+ $properties = $logs[0]->getChangeSet()->getChangedProperties();
+ self::assertSame('Pivo', $properties['title']->getOld());
+ self::assertSame('Pivo 11°', $properties['title']->getNew());
+ self::assertSame(5, $properties['rating']->getNew());
+ }
+
+ public function testGuestIdentityIsStoredAsNull(): void
+ {
+ $article = new Article('Pivo');
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $article->setTitle('Pivo 12°');
+ $this->em->flush();
+
+ $log = EntityManagerFactory::findChangeLogs($this->em)[0];
+
+ self::assertNull($log->getIdentityId());
+ self::assertNull($log->getIdentityClass());
+ }
+
+ public function testCustomValueHandlerIsUsedForTheWholeRoundTrip(): void
+ {
+ $this->em->getConnection()->close();
+ $this->em = EntityManagerFactory::create([new MoneyHandler()]);
+
+ $article = new Article('Pivo');
+ $this->em->persist($article);
+ $this->em->flush();
+
+ // meta is a json column, a value object inside it goes through the registered handler
+ $article->setMeta(['price' => new Money(3900, 'CZK')]);
+ $this->em->flush();
+
+ self::assertStringContainsString('"@type":"money"', $this->fetchRawChangeSet(1));
+
+ $this->em->clear();
+ $meta = $this->reloadFirstChangeLog()->getChangeSet()->getChangedProperties()['meta']->getNew();
+
+ self::assertEquals(new Money(3900, 'CZK'), $meta['price']);
+ }
+
+ public function testTheIdentityOfTheLoggedInUserIsStored(): void
+ {
+ $this->em->getConnection()->close();
+ $this->em = EntityManagerFactory::create([], FakeUser::withIdentity(7));
+
+ $article = new Article('Pivo');
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $article->setTitle('Pivo 12°');
+ $this->em->flush();
+
+ $log = EntityManagerFactory::findChangeLogs($this->em)[0];
+
+ self::assertSame(7, $log->getIdentityId());
+ self::assertSame(SimpleIdentity::class, $log->getIdentityClass());
+ }
+
+ /**
+ * Comment is not logged itself, but Article::$comments is a logged OneToMany, so a change
+ * of a comment has to end up in the log of its article.
+ */
+ public function testAChangeOfANonLoggedChildIsLoggedOnItsParent(): void
+ {
+ $article = new Article('Pivo');
+ $comment = new Comment('Dobré');
+ $article->addComment($comment);
+ $this->em->persist($article);
+ $this->em->persist($comment);
+ $this->em->flush();
+
+ $comment->setText('Výborné');
+ $this->em->flush();
+
+ $this->em->clear();
+ $logs = EntityManagerFactory::findChangeLogs($this->em);
+
+ self::assertCount(1, $logs);
+ self::assertSame(Article::class, $logs[0]->getObjectClass());
+ self::assertSame($article->getId(), $logs[0]->getObjectId());
+
+ $comments = $logs[0]->getChangeSet()->getChangedProperties()['comments'];
+ self::assertInstanceOf(ToMany::class, $comments);
+
+ $nested = array_values($comments->getChangeSets())[0];
+ self::assertSame('Výborné', $nested->getChangedProperties()['text']->getNew());
+ self::assertSame(Comment::class, $nested->getIdentification()->getClass());
+ }
+
+ public function testAddingAChildToALoggedCollectionIsLogged(): void
+ {
+ $article = new Article('Pivo');
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $comment = new Comment('Dobré');
+ $article->addComment($comment);
+ $this->em->persist($comment);
+ $this->em->flush();
+
+ $this->em->clear();
+ $comments = $this->reloadFirstChangeLog()->getChangeSet()->getChangedProperties()['comments'];
+
+ self::assertSame(['Dobré'], array_map(
+ fn ($id) => $id->getIdentification()['text'],
+ array_values($comments->getAdded())
+ ));
+ }
+
+ public function testAChangeOfANotLoggedPropertyIsIgnored(): void
+ {
+ $author = new Author('Franta');
+ $this->em->persist($author);
+ $this->em->flush();
+
+ $author->setName('František');
+ $this->em->flush();
+
+ self::assertSame([], EntityManagerFactory::findChangeLogs($this->em));
+ }
+
+ /**
+ * Documents a gap, not a wanted behaviour: ChangeSet::ACTION_DELETE exists, but a deletion
+ * produces no property change, so the change set is empty and no row is written at all.
+ */
+ public function testDeletingALoggedEntityWritesNoRow(): void
+ {
+ $article = new Article('Pivo');
+ $article->addTag($tag = new Tag('akce'));
+ $this->em->persist($tag);
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $this->em->remove($article);
+ $this->em->flush();
+
+ self::assertSame([], EntityManagerFactory::findChangeLogs($this->em));
+ }
+
+ public function testChangingAnEntityBehindAnInverseOneToOneIsLoggedOnTheOwner(): void
+ {
+ $article = new Article('Pivo');
+ $cover = new Cover('pivo.jpg');
+ $article->setCover($cover);
+ $this->em->persist($cover);
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $cover->setFileName('pivo-12.jpg');
+ $this->em->flush();
+
+ $this->em->clear();
+ $logs = EntityManagerFactory::findChangeLogs($this->em);
+
+ self::assertCount(1, $logs);
+ self::assertSame(Article::class, $logs[0]->getObjectClass());
+
+ $nested = $logs[0]->getChangeSet()->getChangedProperties()['cover']->getChangeSet();
+ self::assertSame('pivo-12.jpg', $nested->getChangedProperties()['fileName']->getNew());
+ self::assertSame(Cover::class, $nested->getIdentification()->getClass());
+ }
+
+ public function testAttachingAnEntityThroughAnOwningToOneIsLogged(): void
+ {
+ $article = new Article('Pivo');
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $author = new Author('Franta');
+ $article->setAuthor($author);
+ $this->em->persist($author);
+ $this->em->flush();
+
+ $this->em->clear();
+ $logged = $this->reloadFirstChangeLog()->getChangeSet()->getChangedProperties()['author'];
+
+ self::assertNull($logged->getOld());
+ self::assertSame(['name' => 'Franta'], $logged->getNew()->getIdentification());
+
+ // documents a gap: the identification of an entity inserted in this very flush is built
+ // before the insert runs, and postPersist updates the object too late for the stored row
+ self::assertSame('', $logged->getNew()->getId());
+ }
+
+ public function testTheStoredJsonIsValidAndCarriesTheFormatVersion(): void
+ {
+ $article = new Article('Pivo');
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $article->setTitle('Pivo 12°');
+ $this->em->flush();
+
+ $json = $this->fetchRawChangeSet(1);
+
+ self::assertJson($json);
+ self::assertSame(ChangeSetSerializer::VERSION, json_decode($json, true)['version']);
+ }
+
+ /**
+ * Regrese: Series::$latestIssue and Issue::$series point at each other, the cache returned
+ * the change set that was still being built and the walk below it ran again every time.
+ * The recursion never closed and ate all the memory, without a single line in the log.
+ */
+ public function testACycleBetweenTwoLoggedPropertiesDoesNotRecurseForever(): void
+ {
+ $series = new Series('Pivní speciály');
+ $series->setLatestIssue($issue = new Issue('Ležák'));
+ $this->em->persist($issue);
+ $this->em->persist($series);
+ $this->em->flush();
+
+ $series->setName('Pivní speciály 2026');
+ $issue->setTitle('Ležák 12°');
+ $this->em->flush();
+
+ $logs = EntityManagerFactory::findChangeLogs($this->em);
+
+ self::assertCount(1, $logs);
+ self::assertSame(Series::class, $logs[0]->getObjectClass());
+
+ $properties = $logs[0]->getChangeSet()->getChangedProperties();
+ self::assertSame('Pivní speciály 2026', $properties['name']->getNew());
+
+ $nested = $properties['latestIssue']->getChangeSet();
+ self::assertSame('Ležák 12°', $nested->getChangedProperties()['title']->getNew());
+ }
+
+ /**
+ * The cycle survives the round trip through the database as a reference, the same change set
+ * instance is on both ends of it.
+ */
+ public function testACyclicChangeSetIsStoredAndReadBackAsAReference(): void
+ {
+ $series = new Series('Pivní speciály');
+ $series->setLatestIssue($issue = new Issue('Ležák'));
+ $this->em->persist($issue);
+ $this->em->persist($series);
+ $this->em->flush();
+
+ $series->setName('Pivní speciály 2026');
+ $this->em->flush();
+
+ $this->em->clear();
+ $changeSet = $this->reloadFirstChangeLog()->getChangeSet();
+
+ $issueChangeSet = $changeSet->getChangedProperties()['latestIssue']->getChangeSet();
+ self::assertSame($changeSet, $issueChangeSet->getChangedProperties()['series']->getChangeSet());
+ }
+
+ /**
+ * Regrese: the changed child used to be looked up by walking the whole collection, so Doctrine
+ * hydrated every row of it just to find one entity and ran out of memory on big ones.
+ */
+ public function testTheChangedChildIsFoundWithoutLoadingTheWholeCollection(): void
+ {
+ $article = new Article('Pivo');
+ foreach (['Dobré', 'Výborné', 'Ujde'] as $text) {
+ $article->addComment($comment = new Comment($text));
+ $this->em->persist($comment);
+ }
+ $this->em->persist($article);
+ $this->em->flush();
+ $commentId = $article->getComments()->first()->getId();
+
+ $this->em->clear();
+ $comment = $this->em->find(Comment::class, $commentId);
+ $comment->setText('Naprosto skvělé');
+ $this->em->flush();
+
+ $comments = $comment->getArticle()->getComments();
+ self::assertInstanceOf(PersistentCollection::class, $comments);
+ self::assertFalse($comments->isInitialized());
+
+ $logs = EntityManagerFactory::findChangeLogs($this->em);
+ self::assertCount(1, $logs);
+
+ $nested = array_values($logs[0]->getChangeSet()->getChangedProperties()['comments']->getChangeSets())[0];
+ self::assertSame('Naprosto skvělé', $nested->getChangedProperties()['text']->getNew());
+ }
+
+ public function testAnAlreadyLoadedCollectionStillFindsTheChangedChild(): void
+ {
+ $article = new Article('Pivo');
+ $article->addComment($comment = new Comment('Dobré'));
+ $this->em->persist($comment);
+ $this->em->persist($article);
+ $this->em->flush();
+ $commentId = $comment->getId();
+
+ $this->em->clear();
+ $comment = $this->em->find(Comment::class, $commentId);
+ $comments = $comment->getArticle()->getComments();
+ $comments->toArray();
+ self::assertTrue($comments->isInitialized());
+
+ $comment->setText('Výborné');
+ $this->em->flush();
+
+ $logs = EntityManagerFactory::findChangeLogs($this->em);
+ self::assertCount(1, $logs);
+
+ $nested = array_values($logs[0]->getChangeSet()->getChangedProperties()['comments']->getChangeSets())[0];
+ self::assertSame('Výborné', $nested->getChangedProperties()['text']->getNew());
+ }
+
+ /**
+ * Regrese: the same changed entity is offered to every collection of the logged entity, so
+ * a comment reached Article::$attachments too and the mappedBy field was read on it. That
+ * property does not exist on a comment and the whole flush died on
+ * "Call to a member function getValue() on null".
+ */
+ public function testACollectionOfAnotherTypeIgnoresTheChangedChild(): void
+ {
+ $article = new Article('Pivo');
+ $article->addComment($comment = new Comment('Dobré'));
+ $article->addAttachment($attachment = new Attachment('pivo.pdf'));
+ $this->em->persist($comment);
+ $this->em->persist($attachment);
+ $this->em->persist($article);
+ $this->em->flush();
+ $commentId = $comment->getId();
+
+ $this->em->clear();
+ $comment = $this->em->find(Comment::class, $commentId);
+ $comment->setText('Výborné');
+ $this->em->flush();
+
+ $attachments = $comment->getArticle()->getAttachments();
+ self::assertInstanceOf(PersistentCollection::class, $attachments);
+ self::assertFalse($attachments->isInitialized());
+
+ $properties = EntityManagerFactory::findChangeLogs($this->em)[0]->getChangeSet()->getChangedProperties();
+ self::assertArrayNotHasKey('attachments', $properties);
+ self::assertArrayHasKey('comments', $properties);
+ }
+
+ /**
+ * A change of a child of the second collection has to be logged the very same way.
+ */
+ public function testAChangeOfAChildOfTheSecondCollectionIsLoggedOnTheParent(): void
+ {
+ $article = new Article('Pivo');
+ $article->addComment($comment = new Comment('Dobré'));
+ $article->addAttachment($attachment = new Attachment('pivo.pdf'));
+ $this->em->persist($comment);
+ $this->em->persist($attachment);
+ $this->em->persist($article);
+ $this->em->flush();
+ $attachmentId = $attachment->getId();
+
+ $this->em->clear();
+ $attachment = $this->em->find(Attachment::class, $attachmentId);
+ $attachment->setFileName('pivo-12.pdf');
+ $this->em->flush();
+
+ $properties = EntityManagerFactory::findChangeLogs($this->em)[0]->getChangeSet()->getChangedProperties();
+
+ self::assertArrayNotHasKey('comments', $properties);
+
+ $nested = array_values($properties['attachments']->getChangeSets())[0];
+ self::assertSame('pivo-12.pdf', $nested->getChangedProperties()['fileName']->getNew());
+ }
+
+ /**
+ * Regrese: an entity loaded through a relation is a proxy and get_class() returns the proxy
+ * class. Its own properties carry attributes of classes the project need not have installed,
+ * and the log row would remember the proxy class instead of the entity.
+ */
+ public function testAProxiedEntityIsLoggedUnderItsRealClass(): void
+ {
+ $article = new Article('Pivo');
+ $this->em->persist($article);
+ $this->em->flush();
+ $articleId = $article->getId();
+
+ $this->em->clear();
+ $article = $this->em->getReference(Article::class, $articleId);
+ $article->setTitle('Pivo 12°');
+ $this->em->flush();
+
+ $logs = EntityManagerFactory::findChangeLogs($this->em);
+
+ self::assertCount(1, $logs);
+ self::assertSame(Article::class, $logs[0]->getObjectClass());
+ self::assertSame('Pivo 12°', $logs[0]->getChangeSet()->getChangedProperties()['title']->getNew());
+ }
+
+ /**
+ * Series::$logo is unidirectional, so the structure cannot store a path back and falls back
+ * to a findOneBy() on the owner. That is the only place in the whole calculation that runs
+ * a query of its own, and a failure there takes the flush down with it.
+ */
+ public function testAChangeBehindAUnidirectionalToOneIsLoggedOnTheOwner(): void
+ {
+ $series = new Series('Pivní speciály');
+ $series->setLogo($logo = new Logo('pivo.svg'));
+ $this->em->persist($logo);
+ $this->em->persist($series);
+ $this->em->flush();
+
+ $logo->setFileName('pivo-12.svg');
+ $this->em->flush();
+
+ $logs = EntityManagerFactory::findChangeLogs($this->em);
+
+ self::assertCount(1, $logs);
+ self::assertSame(Series::class, $logs[0]->getObjectClass());
+
+ $nested = $logs[0]->getChangeSet()->getChangedProperties()['logo']->getChangeSet();
+ self::assertSame('pivo-12.svg', $nested->getChangedProperties()['fileName']->getNew());
+ }
+
+ /**
+ * The one combination that ends up in the log: the child leaves the collection and is deleted,
+ * while its reference back to the parent still stands.
+ */
+ public function testRemovingAndDeletingAChildIsLoggedOnTheParent(): void
+ {
+ $article = new Article('Pivo');
+ $article->addComment($comment = new Comment('Dobré'));
+ $this->em->persist($comment);
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $article->getComments()->removeElement($comment);
+ $this->em->remove($comment);
+ $this->em->flush();
+
+ $this->em->clear();
+ $comments = $this->reloadFirstChangeLog()->getChangeSet()->getChangedProperties()['comments'];
+
+ self::assertSame(['Dobré'], array_map(
+ fn ($id) => $id->getIdentification()['text'],
+ array_values($comments->getRemoved())
+ ));
+ self::assertSame([], $comments->getAdded());
+ }
+
+ /**
+ * Documents a gap: a child taken out of the collection but kept in the database is scheduled
+ * for nothing, and a collection change alone never schedules its owner either. The listener
+ * is therefore never handed anything and the removal is lost.
+ */
+ public function testRemovingAChildWithoutDeletingItIsNotLogged(): void
+ {
+ $article = new Article('Pivo');
+ $article->addComment($comment = new Comment('Dobré'));
+ $this->em->persist($comment);
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $article->getComments()->removeElement($comment);
+ $this->em->flush();
+
+ self::assertSame([], EntityManagerFactory::findChangeLogs($this->em));
+ }
+
+ /**
+ * Documents a trap: the parent is only ever found through the reference the child holds back
+ * to it. Article::removeComment() nulls that reference, which is what such a method normally
+ * does, and the removal disappears from the log even though the child is deleted as well.
+ */
+ public function testNullingTheBackReferenceHidesTheRemovalFromTheLog(): void
+ {
+ $article = new Article('Pivo');
+ $article->addComment($comment = new Comment('Dobré'));
+ $this->em->persist($comment);
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $article->removeComment($comment);
+ $this->em->remove($comment);
+ $this->em->flush();
+
+ self::assertSame([], EntityManagerFactory::findChangeLogs($this->em));
+ }
+
+ /**
+ * Documents a gap, not a wanted behaviour: assigning a brand new collection makes Doctrine
+ * drop the old one wholesale, so the log only ever sees what the new one holds.
+ */
+ public function testReplacingAWholeCollectionLosesWhatWasRemoved(): void
+ {
+ $akce = new Tag('akce');
+ $novinka = new Tag('novinka');
+ $article = new Article('Pivo');
+ $article->addTag($novinka);
+ $this->em->persist($akce);
+ $this->em->persist($novinka);
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $article->replaceTags($akce);
+ $this->em->flush();
+
+ $this->em->clear();
+ $tags = $this->reloadFirstChangeLog()->getChangeSet()->getChangedProperties()['tags'];
+
+ self::assertSame(['akce'], array_map(fn ($id) => $id->getIdentification()['name'], array_values($tags->getAdded())));
+ self::assertSame([], $tags->getRemoved());
+ }
+
+ /**
+ * Chapter is logged itself and at the same time a child of the logged Series::$chapters.
+ * The listener takes the first branch only, so the change lands on the chapter and the
+ * series does not learn about it.
+ */
+ public function testAChildThatIsLoggedItselfGetsItsOwnRowAndNotTheParents(): void
+ {
+ $series = new Series('Pivní speciály');
+ $series->addChapter($chapter = new Chapter('Ležáky'));
+ $this->em->persist($chapter);
+ $this->em->persist($series);
+ $this->em->flush();
+
+ $chapter->setTitle('Ležáky 12°');
+ $this->em->flush();
+
+ $logs = EntityManagerFactory::findChangeLogs($this->em);
+
+ self::assertCount(1, $logs);
+ self::assertSame(Chapter::class, $logs[0]->getObjectClass());
+ self::assertSame('Ležáky 12°', $logs[0]->getChangeSet()->getChangedProperties()['title']->getNew());
+ }
+
+ /**
+ * Documents a limitation: ChangeSetFactory::getIdentifier() is a plain getId() typed ?int, so
+ * a logged entity keyed by a uuid takes the whole flush down. It happens on the very first
+ * insert, because the identifier is read before the insert is dismissed as not worth logging.
+ */
+ public function testALoggedEntityKeyedByAStringIsNotSupported(): void
+ {
+ $this->em->persist(new Poll('a1b2', 'Jaké pivo?'));
+
+ $this->expectException(TypeError::class);
+ $this->em->flush();
+ }
+
+ /**
+ * Deleting the owner of an inverse OneToOne. ChangeSetFactory reads $scheduledEntities here
+ * to report the owner that went away, but nothing ever writes into that array, so the branch
+ * is dead and the deletion leaves no trace on the article.
+ */
+ public function testDeletingTheOwnerOfAnInverseOneToOneLeavesNoTraceOnTheOtherSide(): void
+ {
+ $article = new Article('Pivo');
+ $cover = new Cover('pivo.jpg');
+ $article->setCover($cover);
+ $this->em->persist($cover);
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $this->em->remove($cover);
+ $this->em->flush();
+
+ self::assertSame([], EntityManagerFactory::findChangeLogs($this->em));
+ }
+
+ /**
+ * Regrese: the caches are keyed by spl_object_hash, and PHP hands the hash of a freed object
+ * out again. After clear() the next entity therefore used to get the identification, the
+ * change set and the already detached ChangeLog of the one before it, and the flush died on
+ * "entity is not managed". A long running process that flushes and clears in a loop hit it
+ * sooner or later.
+ */
+ public function testAChangeAfterTheEntityManagerWasClearedIsNotConfusedWithTheOldOne(): void
+ {
+ $article = new Article('Pivo');
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $article->setTitle('Pivo 12°');
+ $this->em->flush();
+
+ // the entity has to be gone for its object hash to be handed out to the next one
+ $this->em->clear();
+ unset($article);
+ gc_collect_cycles();
+
+ $other = new Article('Víno');
+ $this->em->persist($other);
+ $this->em->flush();
+
+ $other->setTitle('Víno bílé');
+ $this->em->flush();
+
+ $this->em->clear();
+ $logs = EntityManagerFactory::findChangeLogs($this->em);
+
+ self::assertCount(2, $logs);
+ self::assertSame('Pivo 12°', $logs[0]->getChangeSet()->getIdentification()->getIdentification()['title']);
+ self::assertSame('Víno bílé', $logs[1]->getChangeSet()->getIdentification()->getIdentification()['title']);
+ self::assertNotSame($logs[0]->getObjectId(), $logs[1]->getObjectId());
+ }
+
+ private function fetchRawChangeSet(int $id): string
+ {
+ return (string) $this->em->getConnection()->fetchOne('SELECT change_set FROM change_log WHERE id = ?', [$id]);
+ }
+
+ private function reloadFirstChangeLog(): ChangeLog
+ {
+ return EntityManagerFactory::findChangeLogs($this->em)[0];
+ }
+}
diff --git a/tests/Rendering/ChangeSetRendererTest.php b/tests/Rendering/ChangeSetRendererTest.php
new file mode 100644
index 0000000..38d9328
--- /dev/null
+++ b/tests/Rendering/ChangeSetRendererTest.php
@@ -0,0 +1,172 @@
+renderer = new ChangeSetRenderer();
+ }
+
+ public function testAnEmptyChangeSetRendersNothing(): void
+ {
+ self::assertSame('', $this->render(new ChangeSet()));
+ }
+
+ public function testAScalarChangeRendersAsATableRow(): void
+ {
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange(new Scalar('title', 'Pivo', 'Pivo 12°'));
+
+ $html = $this->render($changeSet);
+
+ self::assertStringContainsString('', $html);
+ self::assertStringContainsString('| title | ', $html);
+ self::assertStringContainsString('Pivo', $html);
+ self::assertStringContainsString('Pivo 12°', $html);
+ }
+
+ public function testANullValueRendersAsNull(): void
+ {
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange(new Scalar('rating', null, 5));
+
+ self::assertStringContainsString('NULL', $this->render($changeSet));
+ }
+
+ public function testALongValueIsTruncatedButKeptInTheTitle(): void
+ {
+ $long = str_repeat('a', 300);
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange(new Scalar('title', '', $long));
+
+ $html = $this->render($changeSet);
+
+ self::assertStringContainsString('title="' . $long . '"', $html);
+ self::assertStringContainsString('…', $html);
+ }
+
+ public function testAToOneChangeRendersBothIdentifications(): void
+ {
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange(new ToOne(
+ 'author',
+ new Id('1', Author::class, ['name' => 'Franta']),
+ new Id('2', Author::class, ['name' => 'Pepa'])
+ ));
+
+ $html = $this->render($changeSet);
+
+ self::assertStringContainsString('author | ', $html);
+ self::assertStringContainsString('name: Franta', $html);
+ self::assertStringContainsString('name: Pepa', $html);
+ }
+
+ public function testAMissingIdentificationRendersAsNull(): void
+ {
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange(new ToOne('author', null, new Id('2', Author::class, ['name' => 'Pepa'])));
+
+ self::assertStringContainsString('NULL', $this->render($changeSet));
+ }
+
+ public function testANestedChangeSetIsRenderedInPlace(): void
+ {
+ $nested = new ChangeSet();
+ $nested->setIdentification(new Id('1', Author::class, ['name' => 'Franta']));
+ $nested->addPropertyChange(new Scalar('name', 'Franta', 'František'));
+
+ $toOne = new ToOne('author', null, null);
+ $toOne->setChangeSet($nested);
+
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange($toOne);
+
+ $html = $this->render($changeSet);
+
+ self::assertSame(2, substr_count($html, ''));
+ self::assertStringContainsString('František', $html);
+ }
+
+ public function testAToManyChangeRendersARowPerItem(): void
+ {
+ $toMany = new ToMany('tags');
+ $toMany->addAdded(new Id('9', Tag::class, ['name' => 'akce']));
+ $toMany->addAdded(new Id('5', Tag::class, ['name' => 'sleva']));
+ $toMany->addRemoved(new Id('3', Tag::class, ['name' => 'novinka']));
+
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange($toMany);
+
+ $html = $this->render($changeSet);
+
+ self::assertStringContainsString("| tags | ", $html);
+ self::assertStringContainsString('name: akce', $html);
+ self::assertStringContainsString('name: sleva', $html);
+ self::assertStringContainsString('name: novinka', $html);
+ }
+
+ public function testACycleIsRenderedAsRecursionInsteadOfLoopingForever(): void
+ {
+ $article = new ChangeSet();
+ $article->setIdentification(new Id('42', Author::class, ['name' => 'Franta']));
+
+ $author = new ChangeSet();
+ $back = new ToOne('article', null, null);
+ $back->restoreChangeSet($article);
+ $author->restoreProperty($back);
+
+ $toAuthor = new ToOne('author', null, null);
+ $toAuthor->setChangeSet($author);
+ $article->addPropertyChange($toAuthor);
+
+ $html = $this->render($article);
+
+ self::assertStringContainsString('recursion(', $html);
+ self::assertStringContainsString('name: Franta', $html);
+ }
+
+ /**
+ * Regrese: the reset used to assign a misspelled dynamic property, so a change set rendered
+ * twice by the same instance came out as "recursion(...)" the second time.
+ */
+ public function testTheSameChangeSetCanBeRenderedTwice(): void
+ {
+ $changeSet = new ChangeSet();
+ $changeSet->setIdentification(new Id('1', Author::class, ['name' => 'Franta']));
+ $changeSet->addPropertyChange(new Scalar('name', 'Franta', 'František'));
+
+ $first = $this->render($changeSet);
+
+ self::assertSame($first, $this->render($changeSet));
+ self::assertStringNotContainsString('recursion(', $first);
+ }
+
+ private function render(ChangeSet $changeSet): string
+ {
+ ob_start();
+
+ try {
+ $this->renderer->render($changeSet);
+
+ return (string) ob_get_contents();
+ } finally {
+ ob_end_clean();
+ }
+ }
+}
diff --git a/tests/Serializer/ChangeSetSerializerTest.php b/tests/Serializer/ChangeSetSerializerTest.php
new file mode 100644
index 0000000..14b5aa2
--- /dev/null
+++ b/tests/Serializer/ChangeSetSerializerTest.php
@@ -0,0 +1,306 @@
+serializer = new ChangeSetSerializer();
+ }
+
+ public function testScalarChangeSetProducesReadableJson(): void
+ {
+ $changeSet = new ChangeSet();
+ $changeSet->setIdentification(new Id('42', Article::class, ['title' => 'Pivo']));
+ $changeSet->addPropertyChange(new Scalar('title', 'Pivo', 'Pivo 12°'));
+ $changeSet->addPropertyChange(new Scalar('rating', null, 5));
+
+ self::assertSame(
+ [
+ 'version' => 1,
+ 'action' => 'edit',
+ 'entity' => ['class' => Article::class, 'id' => '42', 'identification' => ['title' => 'Pivo']],
+ 'properties' => [
+ 'title' => ['type' => 'scalar', 'old' => 'Pivo', 'new' => 'Pivo 12°'],
+ 'rating' => ['type' => 'scalar', 'old' => null, 'new' => 5],
+ ],
+ ],
+ $this->serializer->toArray($changeSet)
+ );
+ }
+
+ public function testDiacriticsStayReadableInTheStoredJson(): void
+ {
+ $changeSet = new ChangeSet();
+ $changeSet->setIdentification(new Id('42', Article::class, ['title' => 'Nápoje']));
+ $changeSet->addPropertyChange(new Scalar('title', 'Příliš', 'žluťoučký kůň'));
+
+ $json = json_encode($this->serializer->toArray($changeSet), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+
+ self::assertStringContainsString('Nápoje', $json);
+ self::assertStringContainsString('žluťoučký kůň', $json);
+ self::assertStringNotContainsString('\\u', $json);
+ }
+
+ public function testActionIsPreserved(): void
+ {
+ $changeSet = new ChangeSet();
+ $changeSet->setAction(ChangeSet::ACTION_DELETE);
+ $changeSet->addPropertyChange(new Scalar('title', 'Pivo', null));
+
+ $decoded = $this->serializer->fromArray($this->serializer->toArray($changeSet));
+
+ self::assertSame(ChangeSet::ACTION_DELETE, $decoded->getAction());
+ }
+
+ public function testRichScalarValuesRoundTrip(): void
+ {
+ $publishedAt = new DateTimeImmutable('2026-08-27T12:00:00+02:00');
+
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange(new Scalar('publishedAt', null, $publishedAt));
+ $changeSet->addPropertyChange(new Scalar('state', ArticleStateEnum::Draft, ArticleStateEnum::Published));
+ $changeSet->addPropertyChange(new Scalar('meta', null, ['vat' => 21]));
+
+ $decoded = $this->serializer->fromArray($this->serializer->toArray($changeSet));
+ $properties = $decoded->getChangedProperties();
+
+ self::assertSame($publishedAt->format('c'), $properties['publishedAt']->getNew()->format('c'));
+ self::assertSame(ArticleStateEnum::Published, $properties['state']->getNew());
+ self::assertSame(['vat' => 21], $properties['meta']->getNew());
+ }
+
+ public function testToOneWithoutNestedChangeSet(): void
+ {
+ $old = new Id('1', Author::class, ['name' => 'Franta']);
+ $new = new Id('2', Author::class, ['name' => 'Pepa']);
+
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange(new ToOne('author', $old, $new));
+
+ $data = $this->serializer->toArray($changeSet);
+
+ self::assertSame(
+ [
+ 'type' => 'toOne',
+ 'old' => ['class' => Author::class, 'id' => '1', 'identification' => ['name' => 'Franta']],
+ 'new' => ['class' => Author::class, 'id' => '2', 'identification' => ['name' => 'Pepa']],
+ 'changeSet' => null,
+ ],
+ $data['properties']['author']
+ );
+
+ $author = $this->serializer->fromArray($data)->getChangedProperties()['author'];
+ self::assertSame('Franta', $author->getOld()->getIdentification()['name']);
+ self::assertSame('2', $author->getNew()->getId());
+ self::assertNull($author->getChangeSet());
+ }
+
+ public function testNullIdentificationsRoundTrip(): void
+ {
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange(new ToOne('author', null, new Id('2', Author::class, [])));
+
+ $author = $this->serializer->fromArray($this->serializer->toArray($changeSet))->getChangedProperties()['author'];
+
+ self::assertNull($author->getOld());
+ self::assertSame([], $author->getNew()->getIdentification());
+ }
+
+ public function testNestedChangeSetOfAToOneRoundTrips(): void
+ {
+ $nested = new ChangeSet();
+ $nested->setIdentification(new Id('1', Author::class, ['name' => 'Franta']));
+ $nested->addPropertyChange(new Scalar('name', 'Franta', 'František'));
+
+ $toOne = new ToOne('author', new Id('1', Author::class, []), new Id('1', Author::class, []));
+ $toOne->setChangeSet($nested);
+
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange($toOne);
+
+ $decoded = $this->serializer->fromArray($this->serializer->toArray($changeSet));
+ $decodedNested = $decoded->getChangedProperties()['author']->getChangeSet();
+
+ self::assertSame('František', $decodedNested->getChangedProperties()['name']->getNew());
+ }
+
+ public function testToManyRoundTrips(): void
+ {
+ $toMany = new ToMany('tags');
+ $toMany->addAdded(new Id('9', Tag::class, ['name' => 'akce']));
+ $toMany->addRemoved(new Id('3', Tag::class, ['name' => 'novinka']));
+
+ $nested = new ChangeSet();
+ $nested->setIdentification(new Id('9', Tag::class, ['name' => 'akce']));
+ $nested->addPropertyChange(new Scalar('name', 'akce', 'sleva'));
+ $toMany->addChangeSet($nested);
+
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange($toMany);
+
+ $data = $this->serializer->toArray($changeSet);
+ self::assertSame('toMany', $data['properties']['tags']['type']);
+
+ $decoded = $this->serializer->fromArray($data)->getChangedProperties()['tags'];
+ self::assertSame('akce', array_values($decoded->getAdded())[0]->getIdentification()['name']);
+ self::assertSame('novinka', array_values($decoded->getRemoved())[0]->getIdentification()['name']);
+ self::assertSame('sleva', array_values($decoded->getChangeSets())[0]->getChangedProperties()['name']->getNew());
+ }
+
+ public function testToManyKeysAreReindexedAfterAnAddedIdentificationIsRemovedAgain(): void
+ {
+ $first = new Id('1', Tag::class, ['name' => 'a']);
+ $second = new Id('2', Tag::class, ['name' => 'b']);
+
+ $toMany = new ToMany('tags');
+ $toMany->addAdded($first);
+ $toMany->addAdded($second);
+ $toMany->addRemoved($first);
+
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange($toMany);
+
+ $data = $this->serializer->toArray($changeSet);
+
+ self::assertSame([0], array_keys($data['properties']['tags']['added']));
+ self::assertSame('b', $data['properties']['tags']['added'][0]['identification']['name']);
+ }
+
+ public function testChangeSetReferencedOnceCarriesNoIds(): void
+ {
+ $nested = new ChangeSet();
+ $nested->addPropertyChange(new Scalar('name', 'a', 'b'));
+
+ $toOne = new ToOne('author', null, null);
+ $toOne->setChangeSet($nested);
+
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange($toOne);
+
+ $json = json_encode($this->serializer->toArray($changeSet));
+
+ self::assertStringNotContainsString('$id', $json);
+ self::assertStringNotContainsString('$ref', $json);
+ }
+
+ public function testCycleIsStoredAsAReferenceAndRestoresObjectIdentity(): void
+ {
+ $article = new ChangeSet();
+ $article->setIdentification(new Id('42', Article::class, ['title' => 'Pivo']));
+
+ $author = new ChangeSet();
+ $author->setIdentification(new Id('1', Author::class, ['name' => 'Franta']));
+ $author->addPropertyChange(new Scalar('name', 'Franta', 'František'));
+
+ $backToArticle = new ToOne('article', null, null);
+ $backToArticle->restoreChangeSet($article);
+ $author->restoreProperty($backToArticle);
+
+ $toAuthor = new ToOne('author', null, null);
+ $toAuthor->setChangeSet($author);
+ $article->addPropertyChange($toAuthor);
+
+ $data = $this->serializer->toArray($article);
+
+ self::assertSame(1, $data['$id']);
+ self::assertSame(['$ref' => 1], $data['properties']['author']['changeSet']['properties']['article']['changeSet']);
+
+ $decoded = $this->serializer->fromArray($data);
+ $decodedAuthor = $decoded->getChangedProperties()['author']->getChangeSet();
+
+ self::assertSame($decoded, $decodedAuthor->getChangedProperties()['article']->getChangeSet());
+ self::assertSame('František', $decodedAuthor->getChangedProperties()['name']->getNew());
+ }
+
+ public function testSelfReferencingChangeSetRoundTrips(): void
+ {
+ $changeSet = new ChangeSet();
+ $changeSet->setIdentification(new Id('42', Article::class, []));
+
+ $toSelf = new ToOne('parent', null, null);
+ $toSelf->restoreChangeSet($changeSet);
+ $changeSet->restoreProperty($toSelf);
+
+ $decoded = $this->serializer->fromArray($this->serializer->toArray($changeSet));
+
+ self::assertSame($decoded, $decoded->getChangedProperties()['parent']->getChangeSet());
+ }
+
+ public function testTheSameChangeSetUsedTwiceIsStoredOnceAndSharedAfterDecoding(): void
+ {
+ $shared = new ChangeSet();
+ $shared->setIdentification(new Id('1', Author::class, ['name' => 'Franta']));
+ $shared->addPropertyChange(new Scalar('name', 'Franta', 'František'));
+
+ $first = new ToOne('author', null, null);
+ $first->setChangeSet($shared);
+ $second = new ToOne('reviewer', null, null);
+ $second->setChangeSet($shared);
+
+ $changeSet = new ChangeSet();
+ $changeSet->addPropertyChange($first);
+ $changeSet->addPropertyChange($second);
+
+ $data = $this->serializer->toArray($changeSet);
+
+ self::assertSame(1, $data['properties']['author']['changeSet']['$id']);
+ self::assertSame(['$ref' => 1], $data['properties']['reviewer']['changeSet']);
+
+ $decoded = $this->serializer->fromArray($data);
+ self::assertSame(
+ $decoded->getChangedProperties()['author']->getChangeSet(),
+ $decoded->getChangedProperties()['reviewer']->getChangeSet()
+ );
+ }
+
+ public function testDanglingReferenceIsRejected(): void
+ {
+ $this->expectException(UnexpectedValueException::class);
+
+ $this->serializer->fromArray([
+ 'version' => 1,
+ 'action' => 'edit',
+ 'entity' => null,
+ 'properties' => ['author' => ['type' => 'toOne', 'old' => null, 'new' => null, 'changeSet' => ['$ref' => 9]]],
+ ]);
+ }
+
+ public function testNewerFormatVersionIsRejected(): void
+ {
+ $this->expectException(UnexpectedValueException::class);
+
+ $this->serializer->fromArray(['version' => 2, 'action' => 'edit', 'entity' => null, 'properties' => []]);
+ }
+
+ public function testUnknownPropertyTypeIsRejected(): void
+ {
+ $this->expectException(UnexpectedValueException::class);
+
+ $this->serializer->fromArray([
+ 'version' => 1,
+ 'action' => 'edit',
+ 'entity' => null,
+ 'properties' => ['title' => ['type' => 'whatever']],
+ ]);
+ }
+}
diff --git a/tests/Serializer/ValueSerializerTest.php b/tests/Serializer/ValueSerializerTest.php
new file mode 100644
index 0000000..b0be1c7
--- /dev/null
+++ b/tests/Serializer/ValueSerializerTest.php
@@ -0,0 +1,244 @@
+serializer = new ValueSerializer();
+ }
+
+ public static function nativeValues(): array
+ {
+ return [
+ 'null' => [null],
+ 'true' => [true],
+ 'false' => [false],
+ 'zero' => [0],
+ 'int' => [42],
+ 'negative int' => [-7],
+ 'float' => [39.5],
+ 'float without fraction' => [39.0],
+ 'empty string' => [''],
+ 'string' => ['Pivo 12°'],
+ 'numeric string' => ['0042'],
+ ];
+ }
+
+ #[DataProvider('nativeValues')]
+ public function testNativeValuesAreStoredWithoutAnEnvelope(mixed $value): void
+ {
+ self::assertSame($value, $this->serializer->encode($value));
+ self::assertSame($value, $this->serializer->decode($this->serializer->encode($value)));
+ }
+
+ public function testDateTimeImmutableRoundTripsWithItsClass(): void
+ {
+ $value = new DateTimeImmutable('2026-08-27 12:00:00', new \DateTimeZone('Europe/Prague'));
+
+ $encoded = $this->serializer->encode($value);
+
+ self::assertSame(
+ ['@type' => 'datetime', 'class' => DateTimeImmutable::class, 'value' => '2026-08-27T12:00:00+02:00'],
+ $encoded
+ );
+
+ $decoded = $this->serializer->decode($encoded);
+ self::assertInstanceOf(DateTimeImmutable::class, $decoded);
+ self::assertSame($value->format('c'), $decoded->format('c'));
+ }
+
+ public function testMutableDateTimeKeepsItsClass(): void
+ {
+ $decoded = $this->serializer->decode($this->serializer->encode(new DateTime('2026-01-01 00:00:00')));
+
+ self::assertInstanceOf(DateTime::class, $decoded);
+ }
+
+ public function testMicrosecondsAreKeptOnlyWhenPresent(): void
+ {
+ $withMicroseconds = $this->serializer->encode(new DateTimeImmutable('2026-08-27T12:00:00.123456+02:00'));
+ $withoutMicroseconds = $this->serializer->encode(new DateTimeImmutable('2026-08-27T12:00:00+02:00'));
+
+ self::assertSame('2026-08-27T12:00:00.123456+02:00', $withMicroseconds['value']);
+ self::assertSame('2026-08-27T12:00:00+02:00', $withoutMicroseconds['value']);
+ }
+
+ public function testBackedEnumRoundTrips(): void
+ {
+ $encoded = $this->serializer->encode(ArticleStateEnum::Published);
+
+ self::assertSame(
+ ['@type' => 'enum', 'class' => ArticleStateEnum::class, 'value' => 'published'],
+ $encoded
+ );
+ self::assertSame(ArticleStateEnum::Published, $this->serializer->decode($encoded));
+ }
+
+ public function testRemovedEnumCaseDegradesToTheRawValue(): void
+ {
+ $decoded = $this->serializer->decode(['@type' => 'enum', 'class' => ArticleStateEnum::class, 'value' => 'archived']);
+
+ self::assertSame('archived', $decoded);
+ }
+
+ public function testMissingEnumClassDegradesToTheRawValue(): void
+ {
+ $decoded = $this->serializer->decode(['@type' => 'enum', 'class' => 'App\\Gone\\Enum', 'value' => 'draft']);
+
+ self::assertSame('draft', $decoded);
+ }
+
+ public function testArrayIsWrappedSoThatValuePositionsStayUnambiguous(): void
+ {
+ $encoded = $this->serializer->encode(['vat' => 21, 'tags' => ['a', 'b']]);
+
+ self::assertSame(['@type' => 'array', 'value' => ['vat' => 21, 'tags' => ['a', 'b']]], $encoded);
+ self::assertSame(['vat' => 21, 'tags' => ['a', 'b']], $this->serializer->decode($encoded));
+ }
+
+ public function testNestedArraysStayPlainArrays(): void
+ {
+ $value = ['a' => ['b' => ['c' => 1]]];
+
+ self::assertSame(['@type' => 'array', 'value' => $value], $this->serializer->encode($value));
+ self::assertSame($value, $this->serializer->decode($this->serializer->encode($value)));
+ }
+
+ public function testNonJsonValuesInsideAnArrayGetAnEnvelope(): void
+ {
+ $value = ['from' => new DateTimeImmutable('2026-08-27T12:00:00+02:00'), 'state' => ArticleStateEnum::Draft];
+
+ $encoded = $this->serializer->encode($value);
+
+ self::assertSame('datetime', $encoded['value']['from']['@type']);
+ self::assertSame('enum', $encoded['value']['state']['@type']);
+
+ $decoded = $this->serializer->decode($encoded);
+ self::assertSame('2026-08-27T12:00:00+02:00', $decoded['from']->format('c'));
+ self::assertSame(ArticleStateEnum::Draft, $decoded['state']);
+ }
+
+ public function testArrayContainingOurMarkerKeyIsEscapedAndRoundTrips(): void
+ {
+ $value = ['@type' => 'datetime', 'value' => 'not ours'];
+
+ $encoded = $this->serializer->encode($value);
+
+ self::assertSame($value, $this->serializer->decode($encoded));
+ }
+
+ public function testNestedArrayContainingOurMarkerKeyRoundTrips(): void
+ {
+ $value = ['payload' => ['@type' => 'enum', 'class' => 'whatever', 'value' => 'x']];
+
+ self::assertSame($value, $this->serializer->decode($this->serializer->encode($value)));
+ }
+
+ public function testInvalidUtf8StringIsStoredAsBase64(): void
+ {
+ $value = "\x80\xFF binary";
+
+ $encoded = $this->serializer->encode($value);
+
+ self::assertSame('binary', $encoded['@type']);
+ self::assertSame($value, $this->serializer->decode($encoded));
+ self::assertJson(json_encode($encoded, JSON_THROW_ON_ERROR));
+ }
+
+ public function testNonFiniteFloatsRoundTrip(): void
+ {
+ self::assertSame(INF, $this->serializer->decode($this->serializer->encode(INF)));
+ self::assertSame(-INF, $this->serializer->decode($this->serializer->encode(-INF)));
+ self::assertNan($this->serializer->decode($this->serializer->encode(NAN)));
+ }
+
+ public function testStringableObjectFallsBackToItsStringRepresentation(): void
+ {
+ $value = new class implements \Stringable {
+ public function __toString(): string
+ {
+ return '5f2b8c1a';
+ }
+ };
+
+ $encoded = $this->serializer->encode($value);
+
+ self::assertSame('object', $encoded['@type']);
+ self::assertSame('5f2b8c1a', $encoded['value']);
+ self::assertSame('5f2b8c1a', $this->serializer->decode($encoded));
+ }
+
+ public function testJsonSerializableObjectFallsBackToItsJsonRepresentation(): void
+ {
+ $value = new class implements \JsonSerializable {
+ public function jsonSerialize(): array
+ {
+ return ['amount' => 100, 'currency' => 'CZK'];
+ }
+ };
+
+ $encoded = $this->serializer->encode($value);
+
+ self::assertSame(['amount' => 100, 'currency' => 'CZK'], $this->serializer->decode($encoded));
+ }
+
+ public function testUnknownEnvelopeTypeDegradesToItsValueInsteadOfBreakingTheLog(): void
+ {
+ self::assertSame('39.00', $this->serializer->decode(['@type' => 'money', 'value' => '39.00']));
+ }
+
+ public function testBareArrayInAValuePositionIsRejected(): void
+ {
+ $this->expectException(UnexpectedValueException::class);
+
+ $this->serializer->decode(['vat' => 21]);
+ }
+
+ public function testCustomHandlerTakesPrecedenceOverTheBuiltInOnes(): void
+ {
+ $handler = new class implements ValueHandler {
+ public function getType(): string
+ {
+ return 'datetime';
+ }
+
+ public function supports(mixed $value): bool
+ {
+ return $value instanceof DateTimeImmutable;
+ }
+
+ public function encode(mixed $value, ValueSerializer $serializer): array
+ {
+ return ['value' => $value->getTimestamp()];
+ }
+
+ public function decode(array $data, ValueSerializer $serializer): DateTimeImmutable
+ {
+ return new DateTimeImmutable('@' . $data['value']);
+ }
+ };
+
+ $value = new DateTimeImmutable('2026-08-27T12:00:00+02:00');
+ $serializer = new ValueSerializer([$handler]);
+
+ $encoded = $serializer->encode($value);
+
+ self::assertSame(['@type' => 'datetime', 'value' => $value->getTimestamp()], $encoded);
+ self::assertSame($value->getTimestamp(), $serializer->decode($encoded)->getTimestamp());
+ }
+}
diff --git a/tests/Service/ChangeSetFactoryTest.php b/tests/Service/ChangeSetFactoryTest.php
new file mode 100644
index 0000000..bf8df96
--- /dev/null
+++ b/tests/Service/ChangeSetFactoryTest.php
@@ -0,0 +1,302 @@
+em = EntityManagerFactory::create();
+ $this->factory = (new ChangeSetFactory(new FakeUser()))->setEntityManager($this->em);
+ }
+
+ protected function tearDown(): void
+ {
+ $this->em->getConnection()->close();
+ }
+
+ public function testOnlyEntitiesWithTheAttributeAreLogged(): void
+ {
+ self::assertTrue($this->factory->isEntityLogged(Article::class));
+ self::assertFalse($this->factory->isEntityLogged(Author::class));
+ self::assertFalse($this->factory->isEntityLogged(Tag::class));
+ }
+
+ public function testIdentificationCarriesTheClassIdAndTheConfiguredFields(): void
+ {
+ $author = new Author('Franta');
+ $this->em->persist($author);
+ $this->em->flush();
+
+ $identification = $this->factory->createIdentification($author);
+
+ self::assertSame(Author::class, $identification->getClass());
+ self::assertSame((string) $author->getId(), $identification->getId());
+ self::assertSame(['name' => 'Franta'], $identification->getIdentification());
+ }
+
+ public function testIdentificationOfNullIsNull(): void
+ {
+ self::assertNull($this->factory->createIdentification(null));
+ }
+
+ public function testTheSameEntityAlwaysGetsTheSameIdentificationInstance(): void
+ {
+ $author = new Author('Franta');
+
+ self::assertSame($this->factory->createIdentification($author), $this->factory->createIdentification($author));
+ }
+
+ public function testIdentificationFollowsADottedPath(): void
+ {
+ $comment = new Comment('Dobré', new Author('Franta'));
+
+ self::assertSame(
+ ['text' => 'Dobré', 'author.name' => 'Franta'],
+ $this->factory->createIdentification($comment)->getIdentification()
+ );
+ }
+
+ /**
+ * Regrese: a nullable relation along the path used to reach method_exists(null, ...) and
+ * kill the whole flush with a TypeError.
+ */
+ public function testADottedPathThroughANullRelationIsEmptyInsteadOfFatal(): void
+ {
+ $comment = new Comment('Dobré');
+
+ self::assertSame(
+ ['text' => 'Dobré', 'author.name' => ''],
+ $this->factory->createIdentification($comment)->getIdentification()
+ );
+ }
+
+ public function testAnEntityWithoutTheIdentificationAttributeHasNoData(): void
+ {
+ $article = new Article('Pivo');
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $identification = $this->factory->createIdentification($article);
+
+ self::assertSame(['title' => 'Pivo'], $identification->getIdentification());
+ }
+
+ /**
+ * An entity persisted in this very flush has no id when the identification is created,
+ * postPersist fills it in afterwards.
+ */
+ public function testUpdateIdentificationFillsInTheIdAfterPersist(): void
+ {
+ $author = new Author('Franta');
+ $identification = $this->factory->createIdentification($author);
+
+ self::assertSame('', $identification->getId());
+
+ $this->em->persist($author);
+ $this->em->flush();
+ $this->factory->updateIdentification($author);
+
+ self::assertSame((string) $author->getId(), $identification->getId());
+ }
+
+ public function testUpdateIdentificationIgnoresAnUnknownEntity(): void
+ {
+ $this->expectNotToPerformAssertions();
+
+ $this->factory->updateIdentification(new Author('Franta'));
+ }
+
+ public function testADateIdentificationDropsTheTimeAtMidnight(): void
+ {
+ $identification = $this->factory->createIdentification(new Event())->getIdentification();
+
+ self::assertSame('27.8.2026 14:30', $identification['startsAt']);
+ self::assertSame('27.8.2026', $identification['wholeDayAt']);
+ }
+
+ public function testAPathIntoACollectionJoinsEveryValue(): void
+ {
+ $event = new Event();
+ $event->addTag(new Tag('akce'));
+ $event->addTag(new Tag('sleva'));
+
+ self::assertSame('akce, sleva', $this->factory->createIdentification($event)->getIdentification()['tags.name']);
+ }
+
+ public function testAnEmptyCollectionIdentificationIsAnEmptyString(): void
+ {
+ self::assertSame('', $this->factory->createIdentification(new Event())->getIdentification()['tags.name']);
+ }
+
+ /**
+ * Regrese: the logged properties were cached as a numerically indexed list, so a lookup by
+ * property name never found anything.
+ */
+ public function testPropertiesCanBeLookedUpByName(): void
+ {
+ self::assertTrue($this->factory->isPropertyLogged(Article::class, 'title'));
+ self::assertFalse($this->factory->isPropertyLogged(Article::class, 'id'));
+ self::assertFalse($this->factory->isPropertyLogged(Author::class, 'name'));
+
+ self::assertSame('title', $this->factory->getPropertyAnnotation(Article::class, 'title')->getName());
+ self::assertNull($this->factory->getPropertyAnnotation(Article::class, 'id'));
+ }
+
+ public function testAssociationStructureMapsChildrenToThePathBackToTheLoggedEntity(): void
+ {
+ $structure = $this->factory->getLoggableEntityAssociationStructure();
+
+ // Article::$comments is a OneToMany mapped by Comment::$article
+ self::assertArrayHasKey(Comment::class, $structure);
+ self::assertContains(['article'], $structure[Comment::class]);
+ }
+
+ /**
+ * Regrese: for a OneToOne only inversedBy was consulted, so an inverse side fell back to a
+ * findOneBy() on the inverse association and Doctrine threw InvalidFindByCall mid flush.
+ */
+ public function testAssociationStructureFollowsAnInverseOneToOneThroughMappedBy(): void
+ {
+ $structure = $this->factory->getLoggableEntityAssociationStructure();
+
+ self::assertArrayHasKey(Cover::class, $structure);
+ self::assertContains(['article'], $structure[Cover::class]);
+ }
+
+ public function testTheLoggedParentIsFoundThroughAnInverseOneToOne(): void
+ {
+ $article = new Article('Pivo');
+ $cover = new Cover('pivo.jpg');
+ $article->setCover($cover);
+ $this->em->persist($cover);
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $this->factory->getLoggableEntityAssociationStructure();
+
+ self::assertSame($article, $this->factory->getLoggableEntityFromAssociationStructure($cover));
+ }
+
+ public function testAssociationStructureSkipsToManyAndToOneOwningSides(): void
+ {
+ $structure = $this->factory->getLoggableEntityAssociationStructure();
+
+ // Article::$tags is a ManyToMany and Article::$author a ManyToOne, neither leads back
+ self::assertArrayNotHasKey(Tag::class, $structure);
+ self::assertArrayNotHasKey(Author::class, $structure);
+ }
+
+ public function testAssociationStructureIsComputedOnce(): void
+ {
+ self::assertSame(
+ $this->factory->getLoggableEntityAssociationStructure(),
+ $this->factory->getLoggableEntityAssociationStructure()
+ );
+ }
+
+ public function testTheLoggedParentIsFoundFromAChild(): void
+ {
+ $article = new Article('Pivo');
+ $comment = new Comment('Dobré');
+ $article->addComment($comment);
+ $this->em->persist($article);
+ $this->em->persist($comment);
+ $this->em->flush();
+
+ $this->factory->getLoggableEntityAssociationStructure();
+
+ self::assertSame($article, $this->factory->getLoggableEntityFromAssociationStructure($comment));
+ }
+
+ /**
+ * Series::$logo is a unidirectional OneToOne, so there is no property on the logo leading
+ * back. The structure keeps the owner and its property instead of a path.
+ */
+ public function testAssociationStructureFallsBackToTheOwnerWhenThereIsNoWayBack(): void
+ {
+ $structure = $this->factory->getLoggableEntityAssociationStructure();
+
+ self::assertArrayHasKey(Logo::class, $structure);
+ self::assertContains(Series::class . '::logo', $structure[Logo::class]);
+ }
+
+ /**
+ * The only place in the whole calculation that runs a query of its own, so a failure here
+ * takes the flush down with it.
+ */
+ public function testTheLoggedParentIsLookedUpWhenThereIsNoWayBack(): void
+ {
+ $series = new Series('Pivní speciály');
+ $series->setLogo($logo = new Logo('pivo.svg'));
+ $this->em->persist($logo);
+ $this->em->persist($series);
+ $this->em->flush();
+
+ $this->factory->getLoggableEntityAssociationStructure();
+
+ self::assertSame($series, $this->factory->getLoggableEntityFromAssociationStructure($logo));
+ }
+
+ /**
+ * Everything keyed by spl_object_hash has to go once the entities are detached, the hash of
+ * a freed object is handed out again. What is keyed by class name may stay.
+ */
+ public function testOnClearDropsTheCachesKeyedByObjectAndKeepsTheOnesKeyedByClass(): void
+ {
+ $article = new Article('Pivo');
+ $this->em->persist($article);
+ $this->em->flush();
+
+ $this->factory->createIdentification($article);
+ $structure = $this->factory->getLoggableEntityAssociationStructure();
+
+ $this->factory->onClear();
+
+ foreach (['logEntries', 'identifications', 'computedEntityChangeSets'] as $name) {
+ self::assertSame([], $this->readProperty($name), $name . ' must be empty after onClear()');
+ }
+
+ self::assertSame($structure, $this->factory->getLoggableEntityAssociationStructure());
+ self::assertTrue($this->factory->isEntityLogged(Article::class));
+ }
+
+ private function readProperty(string $name): mixed
+ {
+ $property = new ReflectionProperty(ChangeSetFactory::class, $name);
+
+ return $property->getValue($this->factory);
+ }
+
+ public function testAChildWithoutAParentResolvesToNull(): void
+ {
+ $comment = new Comment('Dobré');
+ $this->em->persist($comment);
+ $this->em->flush();
+
+ $this->factory->getLoggableEntityAssociationStructure();
+
+ self::assertNull($this->factory->getLoggableEntityFromAssociationStructure($comment));
+ }
+}
diff --git a/tests/Service/LegacyChangeSetConverterTest.php b/tests/Service/LegacyChangeSetConverterTest.php
new file mode 100644
index 0000000..74a5355
--- /dev/null
+++ b/tests/Service/LegacyChangeSetConverterTest.php
@@ -0,0 +1,274 @@
+connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
+ $this->connection->executeStatement('CREATE TABLE change_log (id INTEGER PRIMARY KEY AUTOINCREMENT, change_set BLOB NOT NULL)');
+
+ $this->serializer = new ChangeSetSerializer();
+ $this->converter = new LegacyChangeSetConverter($this->connection, $this->serializer);
+ }
+
+ protected function tearDown(): void
+ {
+ $this->connection->close();
+ }
+
+ public function testScalarPayloadBecomesJson(): void
+ {
+ $payload = LegacyPayloadBuilder::build(
+ (new ChangeSetStub())
+ ->setIdentification(new IdStub('42', Article::class, ['title' => 'Pivo']))
+ ->addProperty('title', new ScalarStub('Pivo', 'Pivo 12°'))
+ );
+
+ self::assertSame(
+ [
+ 'version' => 1,
+ 'action' => 'edit',
+ 'entity' => ['class' => Article::class, 'id' => '42', 'identification' => ['title' => 'Pivo']],
+ 'properties' => ['title' => ['type' => 'scalar', 'old' => 'Pivo', 'new' => 'Pivo 12°']],
+ ],
+ json_decode($this->converter->convertPayload($payload), true)
+ );
+ }
+
+ /**
+ * Regrese: __sleep() left the names out and __wakeup() put them back from the array keys.
+ * Without restoring them the converted rows would carry an empty property name.
+ */
+ public function testPropertyNamesAreRestoredFromTheArrayKeys(): void
+ {
+ $payload = LegacyPayloadBuilder::build(
+ (new ChangeSetStub())
+ ->addProperty('title', new ScalarStub('Pivo', 'Pivo 12°'))
+ ->addProperty('rating', new ScalarStub(null, 5))
+ );
+
+ $converted = json_decode($this->converter->convertPayload($payload), true);
+
+ self::assertSame(['title', 'rating'], array_keys($converted['properties']));
+ }
+
+ public function testNestedNamesAreRestoredToo(): void
+ {
+ $nested = (new ChangeSetStub())
+ ->setIdentification(new IdStub('1', Author::class, ['name' => 'Franta']))
+ ->addProperty('name', new ScalarStub('Franta', 'František'));
+
+ $payload = LegacyPayloadBuilder::build(
+ (new ChangeSetStub())->addProperty('author', new ToOneStub(null, null, $nested))
+ );
+
+ $converted = json_decode($this->converter->convertPayload($payload), true);
+
+ self::assertSame(['name'], array_keys($converted['properties']['author']['changeSet']['properties']));
+ }
+
+ public function testActionIsCarriedOver(): void
+ {
+ $payload = LegacyPayloadBuilder::build(
+ (new ChangeSetStub())->setAction(ChangeSet::ACTION_DELETE)->addProperty('title', new ScalarStub('Pivo', null))
+ );
+
+ self::assertSame('delete', json_decode($this->converter->convertPayload($payload), true)['action']);
+ }
+
+ public function testToOneAndToManyPayloadsBecomeJson(): void
+ {
+ $payload = LegacyPayloadBuilder::build(
+ (new ChangeSetStub())
+ ->addProperty('author', new ToOneStub(
+ new IdStub('1', Author::class, ['name' => 'Franta']),
+ new IdStub('2', Author::class, ['name' => 'Pepa'])
+ ))
+ ->addProperty('tags', new ToManyStub(
+ [new IdStub('3', Tag::class, ['name' => 'novinka'])],
+ [new IdStub('9', Tag::class, ['name' => 'akce'])]
+ ))
+ );
+
+ $converted = json_decode($this->converter->convertPayload($payload), true);
+
+ self::assertSame('Franta', $converted['properties']['author']['old']['identification']['name']);
+ self::assertSame('Pepa', $converted['properties']['author']['new']['identification']['name']);
+ self::assertSame('akce', $converted['properties']['tags']['added'][0]['identification']['name']);
+ self::assertSame('novinka', $converted['properties']['tags']['removed'][0]['identification']['name']);
+ }
+
+ public function testObjectValuesInsideAScalarAreConverted(): void
+ {
+ $payload = LegacyPayloadBuilder::build(
+ (new ChangeSetStub())->addProperty('publishedAt', new ScalarStub(null, new DateTimeImmutable('2026-08-27T12:00:00+02:00')))
+ );
+
+ $converted = json_decode($this->converter->convertPayload($payload), true);
+
+ self::assertSame(
+ ['@type' => 'datetime', 'class' => DateTimeImmutable::class, 'value' => '2026-08-27T12:00:00+02:00'],
+ $converted['properties']['publishedAt']['new']
+ );
+ }
+
+ public function testCyclicPayloadIsConvertedIntoReferences(): void
+ {
+ $article = new ChangeSetStub();
+ $author = (new ChangeSetStub())->addProperty('article', new ToOneStub(null, null, $article));
+ $article->addProperty('author', new ToOneStub(null, null, $author));
+
+ $converted = json_decode($this->converter->convertPayload(LegacyPayloadBuilder::build($article)), true);
+
+ self::assertSame(1, $converted['$id']);
+ self::assertSame(['$ref' => 1], $converted['properties']['author']['changeSet']['properties']['article']['changeSet']);
+ }
+
+ public function testConvertedPayloadCanBeReadBackByTheSerializer(): void
+ {
+ $payload = LegacyPayloadBuilder::build(
+ (new ChangeSetStub())
+ ->setIdentification(new IdStub('42', Article::class, ['title' => 'Pivo']))
+ ->addProperty('title', new ScalarStub('Pivo', 'Pivo 12°'))
+ );
+
+ $changeSet = $this->serializer->fromArray(json_decode($this->converter->convertPayload($payload), true));
+
+ self::assertSame('Pivo 12°', $changeSet->getChangedProperties()['title']->getNew());
+ self::assertSame('42', $changeSet->getIdentification()->getId());
+ }
+
+ public function testGarbagePayloadIsRejected(): void
+ {
+ $this->expectException(UnexpectedValueException::class);
+
+ $this->converter->convertPayload('not a serialize payload');
+ }
+
+ public function testAllRowsAreConverted(): void
+ {
+ $this->insertLegacyRow('Pivo', 'Pivo 12°');
+ $this->insertLegacyRow('Kofola', 'Kofola 0,5');
+
+ $result = $this->converter->convert();
+
+ self::assertSame(2, $result->getConverted());
+ self::assertSame(0, $result->getSkipped());
+ self::assertFalse($result->hasFailures());
+ self::assertSame('Pivo 12°', $this->readRow(1)->getChangedProperties()['title']->getNew());
+ self::assertSame('Kofola 0,5', $this->readRow(2)->getChangedProperties()['title']->getNew());
+ }
+
+ public function testRunningItAgainSkipsRowsThatAreAlreadyJson(): void
+ {
+ $this->insertLegacyRow('Pivo', 'Pivo 12°');
+ $this->converter->convert();
+
+ $result = $this->converter->convert();
+
+ self::assertSame(0, $result->getConverted());
+ self::assertSame(1, $result->getSkipped());
+ }
+
+ public function testBatchingWalksThroughEveryRow(): void
+ {
+ for ($i = 1; $i <= 7; $i++) {
+ $this->insertLegacyRow('old ' . $i, 'new ' . $i);
+ }
+
+ $batches = [];
+ $result = $this->converter->convert(2, function ($batch) use (&$batches): void {
+ $batches[] = $batch->getConverted();
+ });
+
+ self::assertSame(7, $result->getConverted());
+ self::assertSame([2, 2, 2, 1], $batches);
+ self::assertSame('new 7', $this->readRow(7)->getChangedProperties()['title']->getNew());
+ }
+
+ public function testDryRunLeavesTheRowsAlone(): void
+ {
+ $this->insertLegacyRow('Pivo', 'Pivo 12°');
+
+ $result = $this->converter->convert(500, null, true);
+
+ self::assertSame(1, $result->getConverted());
+ self::assertStringStartsWith('O:', $this->readRawRow(1));
+ }
+
+ public function testABrokenRowIsReportedAndTheRestStillConverts(): void
+ {
+ $this->insertLegacyRow('Pivo', 'Pivo 12°');
+ $this->connection->executeStatement('INSERT INTO change_log (change_set) VALUES (?)', ['garbage']);
+ $this->insertLegacyRow('Kofola', 'Kofola 0,5');
+
+ $result = $this->converter->convert();
+
+ self::assertSame(2, $result->getConverted());
+ self::assertTrue($result->hasFailures());
+ self::assertSame([2], array_keys($result->getFailures()));
+ self::assertSame('garbage', $this->readRawRow(2));
+ self::assertSame('Kofola 0,5', $this->readRow(3)->getChangedProperties()['title']->getNew());
+ }
+
+ public function testCountRowsReportsTheWholeTable(): void
+ {
+ $this->insertLegacyRow('Pivo', 'Pivo 12°');
+ $this->insertLegacyRow('Kofola', 'Kofola 0,5');
+
+ self::assertSame(2, $this->converter->countRows());
+ }
+
+ public function testZeroBatchSizeIsRejected(): void
+ {
+ $this->expectException(UnexpectedValueException::class);
+
+ $this->converter->convert(0);
+ }
+
+ private function insertLegacyRow(string $old, string $new): void
+ {
+ $this->connection->executeStatement(
+ 'INSERT INTO change_log (change_set) VALUES (?)',
+ [LegacyPayloadBuilder::build((new ChangeSetStub())->addProperty('title', new ScalarStub($old, $new)))]
+ );
+ }
+
+ private function readRawRow(int $id): string
+ {
+ return (string) $this->connection->fetchOne('SELECT change_set FROM change_log WHERE id = ?', [$id]);
+ }
+
+ private function readRow(int $id): ChangeSet
+ {
+ return $this->serializer->fromArray(json_decode($this->readRawRow($id), true));
+ }
+}