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('', $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('', $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, '
titleauthor
')); + 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("", $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)); + } +}
tags