From b738f8fbd1e40c4e49b2fbbdd4f59a10437c02d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Konvi=C4=8Dka?= Date: Thu, 27 Aug 2026 21:58:27 +0200 Subject: [PATCH 1/3] Stores change sets as JSON --- .gitignore | 3 +- README.md | 179 +- composer.json | 21 +- composer.lock | 2777 +++++++++++++++-- phpunit.xml | 31 + src/ChangeSet/ChangeSet.php | 16 +- src/ChangeSet/Id.php | 2 +- src/ChangeSet/PropertyChangeSet.php | 9 +- src/ChangeSet/Scalar.php | 7 +- src/ChangeSet/ToMany.php | 19 +- src/ChangeSet/ToOne.php | 11 +- .../ConvertLegacyChangeSetsCommand.php | 81 + src/DI/LoggableExtension.php | 48 + src/Doctrine/ChangeSetType.php | 99 + src/Entity/ChangeLog.php | 11 +- src/Rendering/ChangeSetRenderer.php | 2 +- src/Serializer/ChangeSetSerializer.php | 277 ++ src/Serializer/Handlers/ArrayHandler.php | 37 + src/Serializer/Handlers/BinaryHandler.php | 38 + src/Serializer/Handlers/DateTimeHandler.php | 58 + src/Serializer/Handlers/EnumHandler.php | 58 + src/Serializer/Handlers/FloatHandler.php | 46 + src/Serializer/Handlers/ObjectHandler.php | 51 + src/Serializer/ValueHandler.php | 31 + src/Serializer/ValueSerializer.php | 124 + src/Service/ChangeSetFactory.php | 42 +- .../LegacyChangeSetConversionResult.php | 63 + src/Service/LegacyChangeSetConverter.php | 212 ++ tests/Attributes/AttributeReaderTest.php | 79 + .../Attributes/LoggableIdentificationTest.php | 29 + tests/ChangeSet/ChangeSetTest.php | 84 + tests/ChangeSet/IdTest.php | 32 + tests/ChangeSet/ScalarTest.php | 60 + tests/ChangeSet/ToManyTest.php | 153 + tests/ChangeSet/ToOneTest.php | 93 + .../ConvertLegacyChangeSetsCommandTest.php | 109 + tests/DI/LoggableExtensionTest.php | 94 + tests/Doctrine/ChangeSetTypeTest.php | 94 + tests/Entity/ChangeLogTest.php | 62 + tests/Fixtures/Entity/Article.php | 161 + tests/Fixtures/Entity/ArticleStateEnum.php | 11 + tests/Fixtures/Entity/Author.php | 41 + tests/Fixtures/Entity/Comment.php | 75 + tests/Fixtures/Entity/Cover.php | 58 + tests/Fixtures/Entity/Event.php | 59 + tests/Fixtures/Entity/Tag.php | 36 + tests/Fixtures/EntityManagerFactory.php | 56 + tests/Fixtures/FakeUser.php | 49 + tests/Fixtures/Legacy/ChangeSetStub.php | 36 + tests/Fixtures/Legacy/IdStub.php | 21 + .../Fixtures/Legacy/LegacyPayloadBuilder.php | 36 + tests/Fixtures/Legacy/ScalarStub.php | 18 + tests/Fixtures/Legacy/ToManyStub.php | 26 + tests/Fixtures/Legacy/ToOneStub.php | 21 + tests/Fixtures/Money.php | 12 + tests/Fixtures/MoneyHandler.php | 31 + tests/Fixtures/TestConnectionFactory.php | 16 + tests/Listener/LoggableListenerTest.php | 394 +++ tests/Rendering/ChangeSetRendererTest.php | 172 + tests/Serializer/ChangeSetSerializerTest.php | 306 ++ tests/Serializer/ValueSerializerTest.php | 244 ++ tests/Service/ChangeSetFactoryTest.php | 240 ++ .../Service/LegacyChangeSetConverterTest.php | 274 ++ 63 files changed, 7294 insertions(+), 341 deletions(-) create mode 100644 phpunit.xml create mode 100644 src/Console/ConvertLegacyChangeSetsCommand.php create mode 100644 src/Doctrine/ChangeSetType.php create mode 100644 src/Serializer/ChangeSetSerializer.php create mode 100644 src/Serializer/Handlers/ArrayHandler.php create mode 100644 src/Serializer/Handlers/BinaryHandler.php create mode 100644 src/Serializer/Handlers/DateTimeHandler.php create mode 100644 src/Serializer/Handlers/EnumHandler.php create mode 100644 src/Serializer/Handlers/FloatHandler.php create mode 100644 src/Serializer/Handlers/ObjectHandler.php create mode 100644 src/Serializer/ValueHandler.php create mode 100644 src/Serializer/ValueSerializer.php create mode 100644 src/Service/LegacyChangeSetConversionResult.php create mode 100644 src/Service/LegacyChangeSetConverter.php create mode 100644 tests/Attributes/AttributeReaderTest.php create mode 100644 tests/Attributes/LoggableIdentificationTest.php create mode 100644 tests/ChangeSet/ChangeSetTest.php create mode 100644 tests/ChangeSet/IdTest.php create mode 100644 tests/ChangeSet/ScalarTest.php create mode 100644 tests/ChangeSet/ToManyTest.php create mode 100644 tests/ChangeSet/ToOneTest.php create mode 100644 tests/Console/ConvertLegacyChangeSetsCommandTest.php create mode 100644 tests/DI/LoggableExtensionTest.php create mode 100644 tests/Doctrine/ChangeSetTypeTest.php create mode 100644 tests/Entity/ChangeLogTest.php create mode 100644 tests/Fixtures/Entity/Article.php create mode 100644 tests/Fixtures/Entity/ArticleStateEnum.php create mode 100644 tests/Fixtures/Entity/Author.php create mode 100644 tests/Fixtures/Entity/Comment.php create mode 100644 tests/Fixtures/Entity/Cover.php create mode 100644 tests/Fixtures/Entity/Event.php create mode 100644 tests/Fixtures/Entity/Tag.php create mode 100644 tests/Fixtures/EntityManagerFactory.php create mode 100644 tests/Fixtures/FakeUser.php create mode 100644 tests/Fixtures/Legacy/ChangeSetStub.php create mode 100644 tests/Fixtures/Legacy/IdStub.php create mode 100644 tests/Fixtures/Legacy/LegacyPayloadBuilder.php create mode 100644 tests/Fixtures/Legacy/ScalarStub.php create mode 100644 tests/Fixtures/Legacy/ToManyStub.php create mode 100644 tests/Fixtures/Legacy/ToOneStub.php create mode 100644 tests/Fixtures/Money.php create mode 100644 tests/Fixtures/MoneyHandler.php create mode 100644 tests/Fixtures/TestConnectionFactory.php create mode 100644 tests/Listener/LoggableListenerTest.php create mode 100644 tests/Rendering/ChangeSetRendererTest.php create mode 100644 tests/Serializer/ChangeSetSerializerTest.php create mode 100644 tests/Serializer/ValueSerializerTest.php create mode 100644 tests/Service/ChangeSetFactoryTest.php create mode 100644 tests/Service/LegacyChangeSetConverterTest.php 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/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..f9f30fc 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(); } @@ -431,8 +440,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 +502,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 +556,7 @@ protected function convertIdentificationValue($value) /** * @param $entityClassName - * @return ReflectionProperty[] + * @return ReflectionProperty[] keyed by property name * @throws ReflectionException */ protected function getLoggedProperties($entityClassName): array @@ -550,7 +567,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 +615,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..b3b4a2a --- /dev/null +++ b/tests/Fixtures/Entity/Article.php @@ -0,0 +1,161 @@ + */ + #[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; + + public function __construct(string $title) + { + $this->title = $title; + $this->tags = new ArrayCollection(); + $this->comments = 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); + } + + 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); + } +} 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 @@ +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/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/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..474bde1 --- /dev/null +++ b/tests/Listener/LoggableListenerTest.php @@ -0,0 +1,394 @@ +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']); + } + + 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..32db8ba --- /dev/null +++ b/tests/Service/ChangeSetFactoryTest.php @@ -0,0 +1,240 @@ +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)); + } + + 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)); + } +} From 94075641ae2dbe7e39acb7a21e683918cd6e4b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Konvi=C4=8Dka?= Date: Mon, 31 Aug 2026 12:22:51 +0200 Subject: [PATCH 2/3] Cover the cycle guard and the collection lookup with tests Regression tests for both fixes rebased from master: 1. Series and Issue point at each other through logged properties, which used to send getChangeSet() into an endless recursion until the memory ran out. The cyclic change set is asserted to round trip through the database as a reference too. 2. The changed child is found in an uninitialized collection without hydrating it, and a collection of another type is skipped instead of reading a mappedBy field the changed entity does not have. Writing them uncovered a long standing bug: the logged properties were read from the proxy class of an entity loaded through a relation. The proxy declares properties of its own carrying attributes of classes a project need not have installed, so the whole flush died on "Attribute class ... not found". --- src/Service/ChangeSetFactory.php | 3 +- tests/Fixtures/Entity/Article.php | 18 +++ tests/Fixtures/Entity/Attachment.php | 58 +++++++ tests/Fixtures/Entity/Issue.php | 59 ++++++++ tests/Fixtures/Entity/Series.php | 64 ++++++++ tests/Listener/LoggableListenerTest.php | 193 ++++++++++++++++++++++++ 6 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 tests/Fixtures/Entity/Attachment.php create mode 100644 tests/Fixtures/Entity/Issue.php create mode 100644 tests/Fixtures/Entity/Series.php diff --git a/src/Service/ChangeSetFactory.php b/src/Service/ChangeSetFactory.php index f9f30fc..0f44838 100644 --- a/src/Service/ChangeSetFactory.php +++ b/src/Service/ChangeSetFactory.php @@ -286,7 +286,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) { diff --git a/tests/Fixtures/Entity/Article.php b/tests/Fixtures/Entity/Article.php index b3b4a2a..09f4a60 100644 --- a/tests/Fixtures/Entity/Article.php +++ b/tests/Fixtures/Entity/Article.php @@ -59,11 +59,17 @@ class 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 @@ -158,4 +164,16 @@ 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/Attachment.php b/tests/Fixtures/Entity/Attachment.php new file mode 100644 index 0000000..39f0a9e --- /dev/null +++ b/tests/Fixtures/Entity/Attachment.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 getOwner(): ?Article + { + return $this->owner; + } + + public function setOwner(?Article $owner): void + { + $this->owner = $owner; + } +} 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/Series.php b/tests/Fixtures/Entity/Series.php new file mode 100644 index 0000000..1b6d213 --- /dev/null +++ b/tests/Fixtures/Entity/Series.php @@ -0,0 +1,64 @@ +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; + } + + public function getLatestIssue(): ?Issue + { + return $this->latestIssue; + } + + public function setLatestIssue(?Issue $latestIssue): void + { + $this->latestIssue = $latestIssue; + + if ($latestIssue !== null) { + $latestIssue->setSeries($this); + } + } +} diff --git a/tests/Listener/LoggableListenerTest.php b/tests/Listener/LoggableListenerTest.php index 474bde1..35111f5 100644 --- a/tests/Listener/LoggableListenerTest.php +++ b/tests/Listener/LoggableListenerTest.php @@ -11,10 +11,13 @@ use ADT\DoctrineLoggable\Entity\ChangeLog; use ADT\DoctrineLoggable\Serializer\ChangeSetSerializer; use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Article; +use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Attachment; use ADT\DoctrineLoggable\Tests\Fixtures\Entity\ArticleStateEnum; use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Author; use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Comment; use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Cover; +use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Issue; +use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Series; use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Tag; use ADT\DoctrineLoggable\Tests\Fixtures\EntityManagerFactory; use ADT\DoctrineLoggable\Tests\Fixtures\FakeUser; @@ -22,6 +25,7 @@ use ADT\DoctrineLoggable\Tests\Fixtures\MoneyHandler; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\PersistentCollection; use Nette\Security\SimpleIdentity; use PHPUnit\Framework\TestCase; @@ -382,6 +386,195 @@ public function testTheStoredJsonIsValidAndCarriesTheFormatVersion(): void 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()); + } + private function fetchRawChangeSet(int $id): string { return (string) $this->em->getConnection()->fetchOne('SELECT change_set FROM change_log WHERE id = ?', [$id]); From 2761b701684551459b7368349a9fe8a436daec95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Konvi=C4=8Dka?= Date: Mon, 31 Aug 2026 13:10:01 +0200 Subject: [PATCH 3/3] Drop the object keyed caches when the entity manager is cleared The caches of ChangeSetFactory are keyed by spl_object_hash, and PHP hands the hash of a freed object out again. After a clear() the next entity got 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. The listener now subscribes to onClear and resets them; the caches keyed by class name stay. Also covers the paths the suite had no fixture for: - a unidirectional OneToOne, the only case where the parent is looked up with a query instead of a path back, - a child of a logged collection that is logged itself, which lands on its own row and not on the parent's, - the three ways of taking a child out of a logged collection that leave no trace in the log at all, including the one a plain removeX() causes, - replacing a whole collection, which loses what was removed, - a logged entity keyed by a string, which the ?int getIdentifier() cannot take, - deleting the owner of an inverse OneToOne, where the branch meant to report it reads $scheduledEntities that nothing ever writes into. --- src/Listener/LoggableListener.php | 7 + src/Service/ChangeSetFactory.php | 20 +++ tests/Fixtures/Entity/Article.php | 6 + tests/Fixtures/Entity/Chapter.php | 59 +++++++ tests/Fixtures/Entity/Logo.php | 46 +++++ tests/Fixtures/Entity/Poll.php | 46 +++++ tests/Fixtures/Entity/Series.php | 35 ++++ tests/Listener/LoggableListenerTest.php | 212 ++++++++++++++++++++++++ tests/Service/ChangeSetFactoryTest.php | 62 +++++++ 9 files changed, 493 insertions(+) create mode 100644 tests/Fixtures/Entity/Chapter.php create mode 100644 tests/Fixtures/Entity/Logo.php create mode 100644 tests/Fixtures/Entity/Poll.php 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/Service/ChangeSetFactory.php b/src/Service/ChangeSetFactory.php index 0f44838..1751106 100644 --- a/src/Service/ChangeSetFactory.php +++ b/src/Service/ChangeSetFactory.php @@ -215,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); diff --git a/tests/Fixtures/Entity/Article.php b/tests/Fixtures/Entity/Article.php index 09f4a60..265e20c 100644 --- a/tests/Fixtures/Entity/Article.php +++ b/tests/Fixtures/Entity/Article.php @@ -133,6 +133,12 @@ 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; 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/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 index 1b6d213..41e4da1 100644 --- a/tests/Fixtures/Entity/Series.php +++ b/tests/Fixtures/Entity/Series.php @@ -5,6 +5,8 @@ namespace ADT\DoctrineLoggable\Tests\Fixtures\Entity; use ADT\DoctrineLoggable\Attributes as ADA; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; /** @@ -28,9 +30,20 @@ class Series #[ADA\LoggableProperty] private ?Issue $latestIssue = null; + /** Unidirectional, Logo holds no reference back here. */ + #[ORM\OneToOne(targetEntity: Logo::class)] + #[ADA\LoggableProperty] + private ?Logo $logo = null; + + /** @var Collection */ + #[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 @@ -61,4 +74,26 @@ public function setLatestIssue(?Issue $latestIssue): void $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/Listener/LoggableListenerTest.php b/tests/Listener/LoggableListenerTest.php index 35111f5..3c79f23 100644 --- a/tests/Listener/LoggableListenerTest.php +++ b/tests/Listener/LoggableListenerTest.php @@ -15,8 +15,11 @@ use ADT\DoctrineLoggable\Tests\Fixtures\Entity\ArticleStateEnum; use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Author; use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Comment; +use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Chapter; use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Cover; use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Issue; +use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Logo; +use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Poll; use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Series; use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Tag; use ADT\DoctrineLoggable\Tests\Fixtures\EntityManagerFactory; @@ -27,6 +30,7 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\PersistentCollection; use Nette\Security\SimpleIdentity; +use TypeError; use PHPUnit\Framework\TestCase; final class LoggableListenerTest extends TestCase @@ -575,6 +579,214 @@ public function testAProxiedEntityIsLoggedUnderItsRealClass(): void 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]); diff --git a/tests/Service/ChangeSetFactoryTest.php b/tests/Service/ChangeSetFactoryTest.php index 32db8ba..bf8df96 100644 --- a/tests/Service/ChangeSetFactoryTest.php +++ b/tests/Service/ChangeSetFactoryTest.php @@ -10,11 +10,14 @@ use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Comment; use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Cover; use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Event; +use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Logo; +use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Series; use ADT\DoctrineLoggable\Tests\Fixtures\Entity\Tag; use ADT\DoctrineLoggable\Tests\Fixtures\EntityManagerFactory; use ADT\DoctrineLoggable\Tests\Fixtures\FakeUser; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; +use ReflectionProperty; final class ChangeSetFactoryTest extends TestCase { @@ -227,6 +230,65 @@ public function testTheLoggedParentIsFoundFromAChild(): void 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é');
tags