diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 3bc9f35..9acdc60 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -14,6 +14,10 @@ $config = new PhpCsFixer\Config(); $config->setFinder($finder); +// Allow running on PHP versions php-cs-fixer doesn't officially support yet +// (we run on the latest stable PHP). Replaces the deprecated +// PHP_CS_FIXER_IGNORE_ENV env var. +$config->setUnsupportedPhpVersionAllowed(true); $config->setRules([ '@Symfony' => true, diff --git a/CHANGELOG.md b/CHANGELOG.md index acd3eda..7ee3096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.11.0] - 2026-05-19 + +- [#78](https://github.com/itk-dev/devops_itksites/pull/78) + Update composer dependencies, fix php-cs-fixer deprecation +- [#77](https://github.com/itk-dev/devops_itksites/pull/77) + Fix SemverFilter: respect value2 with directional operators +- [#76](https://github.com/itk-dev/devops_itksites/pull/76) + Add server type filter and sort on Installation, Site, Domain +- [#75](https://github.com/itk-dev/devops_itksites/pull/75) + Add semver-aware filter on every admin version column, make version + column semver sortable + ## [1.10.1] - 2026-05-11 - [#71](https://github.com/itk-dev/devops_itksites/pull/71) @@ -164,7 +176,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.0] - 2022-09-15 -[Unreleased]: https://github.com/itk-dev/devops_itksites/compare/1.10.0...HEAD +[Unreleased]: https://github.com/itk-dev/devops_itksites/compare/1.11.0...HEAD +[1.11.0]: https://github.com/itk-dev/devops_itksites/compare/1.10.1...1.11.0 +[1.10.1]: https://github.com/itk-dev/devops_itksites/compare/1.10.0...1.10.1 [1.10.0]: https://github.com/itk-dev/devops_itksites/compare/1.9.2...1.10.0 [1.9.2]: https://github.com/itk-dev/devops_itksites/compare/1.9.1...1.9.2 [1.9.1]: https://github.com/itk-dev/devops_itksites/compare/1.9.0...1.9.1 diff --git a/composer.json b/composer.json index 4684cf3..1737658 100644 --- a/composer.json +++ b/composer.json @@ -114,10 +114,10 @@ "assets:install %PUBLIC_DIR%": "symfony-cmd" }, "coding-standards-apply": [ - "PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix" + "vendor/bin/php-cs-fixer fix" ], "coding-standards-check": [ - "PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-run" + "vendor/bin/php-cs-fixer fix --dry-run" ], "fixtures-load": [ "bin/console hautelook:fixtures:load --no-interaction" diff --git a/composer.lock b/composer.lock index 78480ce..9392a9d 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "api-platform/core", - "version": "v4.3.4", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/api-platform/core.git", - "reference": "fffcb25bb6362e6bc8e42bd6e9bd3fab10e05209" + "reference": "ad027fbbe1b64294174b962c9d9f02d0a3bca7a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/core/zipball/fffcb25bb6362e6bc8e42bd6e9bd3fab10e05209", - "reference": "fffcb25bb6362e6bc8e42bd6e9bd3fab10e05209", + "url": "https://api.github.com/repos/api-platform/core/zipball/ad027fbbe1b64294174b962c9d9f02d0a3bca7a7", + "reference": "ad027fbbe1b64294174b962c9d9f02d0a3bca7a7", "shasum": "" }, "require": { @@ -28,7 +28,7 @@ "psr/container": "^1.0 || ^2.0", "symfony/deprecation-contracts": "^3.1", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", - "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", "symfony/property-info": "^6.4 || ^7.1 || ^8.0", "symfony/serializer": "^6.4 || ^7.0 || ^8.0", @@ -97,7 +97,7 @@ "jangregor/phpstan-prophecy": "^2.1.11", "justinrainbow/json-schema": "^6.5.2", "laravel/framework": "^11.0 || ^12.0 || ^13.0", - "mcp/sdk": "^0.4.0", + "mcp/sdk": ">=0.4 <1.0", "orchestra/testbench": "^10.9 || ^11.0", "phpspec/prophecy-phpunit": "^2.2", "phpstan/extension-installer": "^1.1", @@ -226,9 +226,9 @@ ], "support": { "issues": "https://github.com/api-platform/core/issues", - "source": "https://github.com/api-platform/core/tree/v4.3.4" + "source": "https://github.com/api-platform/core/tree/v4.3.5" }, - "time": "2026-04-30T12:58:19+00:00" + "time": "2026-05-11T12:20:22+00:00" }, { "name": "composer/semver", @@ -1423,16 +1423,16 @@ }, { "name": "easycorp/easyadmin-bundle", - "version": "v5.0.7", + "version": "v5.0.8", "source": { "type": "git", "url": "https://github.com/EasyCorp/EasyAdminBundle.git", - "reference": "b19dcd69603eeae35b81cf6dde1078729e3b140c" + "reference": "0feafc61cd7a2b990c2d7280fd7a450696a7fe92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/EasyCorp/EasyAdminBundle/zipball/b19dcd69603eeae35b81cf6dde1078729e3b140c", - "reference": "b19dcd69603eeae35b81cf6dde1078729e3b140c", + "url": "https://api.github.com/repos/EasyCorp/EasyAdminBundle/zipball/0feafc61cd7a2b990c2d7280fd7a450696a7fe92", + "reference": "0feafc61cd7a2b990c2d7280fd7a450696a7fe92", "shasum": "" }, "require": { @@ -1518,7 +1518,7 @@ ], "support": { "issues": "https://github.com/EasyCorp/EasyAdminBundle/issues", - "source": "https://github.com/EasyCorp/EasyAdminBundle/tree/v5.0.7" + "source": "https://github.com/EasyCorp/EasyAdminBundle/tree/v5.0.8" }, "funding": [ { @@ -1526,7 +1526,7 @@ "type": "github" } ], - "time": "2026-05-03T18:16:57+00:00" + "time": "2026-05-13T17:18:33+00:00" }, { "name": "firebase/php-jwt", @@ -3176,16 +3176,16 @@ }, { "name": "symfony/amqp-messenger", - "version": "v8.0.9", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/amqp-messenger.git", - "reference": "59591bb7ca054ca20a9fff0785540a19c39794fb" + "reference": "26e1eec5f9aa5cd8dd43d02cc80de468c0fa480e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/amqp-messenger/zipball/59591bb7ca054ca20a9fff0785540a19c39794fb", - "reference": "59591bb7ca054ca20a9fff0785540a19c39794fb", + "url": "https://api.github.com/repos/symfony/amqp-messenger/zipball/26e1eec5f9aa5cd8dd43d02cc80de468c0fa480e", + "reference": "26e1eec5f9aa5cd8dd43d02cc80de468c0fa480e", "shasum": "" }, "require": { @@ -3225,7 +3225,7 @@ "description": "Symfony AMQP extension Messenger Bridge", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/amqp-messenger/tree/v8.0.9" + "source": "https://github.com/symfony/amqp-messenger/tree/v8.0.11" }, "funding": [ { @@ -3245,7 +3245,7 @@ "type": "tidelift" } ], - "time": "2026-04-30T16:10:06+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/asset", @@ -3725,16 +3725,16 @@ }, { "name": "symfony/console", - "version": "v8.0.9", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d" + "reference": "3156577f46a38aa1b9323aad223de7a9cd426782" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/7113778e2e91f4709cb3194a75dfa9c0d028d94d", - "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d", + "url": "https://api.github.com/repos/symfony/console/zipball/3156577f46a38aa1b9323aad223de7a9cd426782", + "reference": "3156577f46a38aa1b9323aad223de7a9cd426782", "shasum": "" }, "require": { @@ -3791,7 +3791,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.9" + "source": "https://github.com/symfony/console/tree/v8.0.11" }, "funding": [ { @@ -3811,7 +3811,7 @@ "type": "tidelift" } ], - "time": "2026-04-29T15:02:55+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/dependency-injection", @@ -4215,16 +4215,16 @@ }, { "name": "symfony/dotenv", - "version": "v8.0.9", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", - "reference": "f75c67be2c2648741c4b163546002e34265bcb91" + "reference": "82e1d8f888896a215bb6673e6d1f6d5ca47a9dfe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/f75c67be2c2648741c4b163546002e34265bcb91", - "reference": "f75c67be2c2648741c4b163546002e34265bcb91", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/82e1d8f888896a215bb6673e6d1f6d5ca47a9dfe", + "reference": "82e1d8f888896a215bb6673e6d1f6d5ca47a9dfe", "shasum": "" }, "require": { @@ -4265,7 +4265,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v8.0.9" + "source": "https://github.com/symfony/dotenv/tree/v8.0.11" }, "funding": [ { @@ -4285,7 +4285,7 @@ "type": "tidelift" } ], - "time": "2026-04-29T15:02:55+00:00" + "time": "2026-05-11T13:06:45+00:00" }, { "name": "symfony/error-handler", @@ -4602,16 +4602,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.9", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d1ec4543d5c6c2dac78503c2fae5ea0b3608ce40" + "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d1ec4543d5c6c2dac78503c2fae5ea0b3608ce40", - "reference": "d1ec4543d5c6c2dac78503c2fae5ea0b3608ce40", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/224db910898ce1317b892a9a1338f1f8f17eb7c7", + "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7", "shasum": "" }, "require": { @@ -4648,7 +4648,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.9" + "source": "https://github.com/symfony/filesystem/tree/v8.0.11" }, "funding": [ { @@ -4668,7 +4668,7 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:51:42+00:00" + "time": "2026-05-11T16:39:47+00:00" }, { "name": "symfony/finder", @@ -4908,16 +4908,16 @@ }, { "name": "symfony/framework-bundle", - "version": "v8.0.10", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "a81e320168f53a798007978dac23113b321173e3" + "reference": "c0d53dba8de800f5dd1e9dac79683d8c59934d34" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/a81e320168f53a798007978dac23113b321173e3", - "reference": "a81e320168f53a798007978dac23113b321173e3", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/c0d53dba8de800f5dd1e9dac79683d8c59934d34", + "reference": "c0d53dba8de800f5dd1e9dac79683d8c59934d34", "shasum": "" }, "require": { @@ -5025,7 +5025,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v8.0.10" + "source": "https://github.com/symfony/framework-bundle/tree/v8.0.11" }, "funding": [ { @@ -5045,7 +5045,7 @@ "type": "tidelift" } ], - "time": "2026-05-05T12:00:57+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/http-client", @@ -5307,16 +5307,16 @@ }, { "name": "symfony/http-kernel", - "version": "v8.0.10", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "fb3f65b3d4ca2dad31c80d323819a762ca31d6ac" + "reference": "20d3680373f4b791903c09e74b45402b4aeda71c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/fb3f65b3d4ca2dad31c80d323819a762ca31d6ac", - "reference": "fb3f65b3d4ca2dad31c80d323819a762ca31d6ac", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/20d3680373f4b791903c09e74b45402b4aeda71c", + "reference": "20d3680373f4b791903c09e74b45402b4aeda71c", "shasum": "" }, "require": { @@ -5387,7 +5387,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.0.10" + "source": "https://github.com/symfony/http-kernel/tree/v8.0.11" }, "funding": [ { @@ -5407,7 +5407,7 @@ "type": "tidelift" } ], - "time": "2026-05-06T12:27:31+00:00" + "time": "2026-05-13T18:07:14+00:00" }, { "name": "symfony/intl", @@ -5500,16 +5500,16 @@ }, { "name": "symfony/messenger", - "version": "v8.0.10", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/messenger.git", - "reference": "29a4a7c5f5e24913d374fb724feea18764a8a86c" + "reference": "c451c175724fc781c777783aaec3b7999ceb0621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/messenger/zipball/29a4a7c5f5e24913d374fb724feea18764a8a86c", - "reference": "29a4a7c5f5e24913d374fb724feea18764a8a86c", + "url": "https://api.github.com/repos/symfony/messenger/zipball/c451c175724fc781c777783aaec3b7999ceb0621", + "reference": "c451c175724fc781c777783aaec3b7999ceb0621", "shasum": "" }, "require": { @@ -5566,7 +5566,7 @@ "description": "Helps applications send and receive messages to/from other applications or via message queues", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/messenger/tree/v8.0.10" + "source": "https://github.com/symfony/messenger/tree/v8.0.11" }, "funding": [ { @@ -5586,7 +5586,7 @@ "type": "tidelift" } ], - "time": "2026-05-06T11:30:54+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/mime", @@ -6976,16 +6976,16 @@ }, { "name": "symfony/security-bundle", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "00a13be2abf3fe9baf54e1e16e7583ca9c708f09" + "reference": "c7ae56efdc872704101a55b86f950b111aea9a06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/00a13be2abf3fe9baf54e1e16e7583ca9c708f09", - "reference": "00a13be2abf3fe9baf54e1e16e7583ca9c708f09", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/c7ae56efdc872704101a55b86f950b111aea9a06", + "reference": "c7ae56efdc872704101a55b86f950b111aea9a06", "shasum": "" }, "require": { @@ -7052,7 +7052,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v8.0.8" + "source": "https://github.com/symfony/security-bundle/tree/v8.0.11" }, "funding": [ { @@ -7072,20 +7072,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/security-core", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "8456ed58e22f59a4c50f50d7dd82b2f41d162c5f" + "reference": "7aa47b511c07734bbb3490046ca8cdff1bf4fbef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/8456ed58e22f59a4c50f50d7dd82b2f41d162c5f", - "reference": "8456ed58e22f59a4c50f50d7dd82b2f41d162c5f", + "url": "https://api.github.com/repos/symfony/security-core/zipball/7aa47b511c07734bbb3490046ca8cdff1bf4fbef", + "reference": "7aa47b511c07734bbb3490046ca8cdff1bf4fbef", "shasum": "" }, "require": { @@ -7134,7 +7134,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v8.0.8" + "source": "https://github.com/symfony/security-core/tree/v8.0.11" }, "funding": [ { @@ -7154,7 +7154,7 @@ "type": "tidelift" } ], - "time": "2026-03-31T07:15:36+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/security-csrf", @@ -7229,16 +7229,16 @@ }, { "name": "symfony/security-http", - "version": "v8.0.9", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "51fff88fc8436e42b4d92cae005f86b9233e0f88" + "reference": "3c29d0118c6bc5919ce6f2ef4e9ead24503ca819" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/51fff88fc8436e42b4d92cae005f86b9233e0f88", - "reference": "51fff88fc8436e42b4d92cae005f86b9233e0f88", + "url": "https://api.github.com/repos/symfony/security-http/zipball/3c29d0118c6bc5919ce6f2ef4e9ead24503ca819", + "reference": "3c29d0118c6bc5919ce6f2ef4e9ead24503ca819", "shasum": "" }, "require": { @@ -7292,7 +7292,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v8.0.9" + "source": "https://github.com/symfony/security-http/tree/v8.0.11" }, "funding": [ { @@ -7312,7 +7312,7 @@ "type": "tidelift" } ], - "time": "2026-04-29T15:02:55+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/serializer", @@ -7567,16 +7567,16 @@ }, { "name": "symfony/string", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", - "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", + "url": "https://api.github.com/repos/symfony/string/zipball/39be2ad058a3c0bd558edca23e65f009865d75ff", + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff", "shasum": "" }, "require": { @@ -7633,7 +7633,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.8" + "source": "https://github.com/symfony/string/tree/v8.0.11" }, "funding": [ { @@ -7653,7 +7653,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/translation", @@ -8694,16 +8694,16 @@ }, { "name": "symfony/yaml", - "version": "v8.0.10", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "aa9ee60c41d9b20a2468c41ff0a32e2a7405ac05" + "reference": "48046fbd5567bd1717f278eaa2cfc3131f489984" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/aa9ee60c41d9b20a2468c41ff0a32e2a7405ac05", - "reference": "aa9ee60c41d9b20a2468c41ff0a32e2a7405ac05", + "url": "https://api.github.com/repos/symfony/yaml/zipball/48046fbd5567bd1717f278eaa2cfc3131f489984", + "reference": "48046fbd5567bd1717f278eaa2cfc3131f489984", "shasum": "" }, "require": { @@ -8745,7 +8745,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.0.10" + "source": "https://github.com/symfony/yaml/tree/v8.0.11" }, "funding": [ { @@ -8765,7 +8765,7 @@ "type": "tidelift" } ], - "time": "2026-05-05T08:10:04+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "twig/extra-bundle", @@ -8911,16 +8911,16 @@ }, { "name": "twig/twig", - "version": "v3.24.0", + "version": "v3.25.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "a6769aefb305efef849dc25c9fd1653358c148f0" + "reference": "0dade995be754556af4dcbf8721d45cb3271f9b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/a6769aefb305efef849dc25c9fd1653358c148f0", - "reference": "a6769aefb305efef849dc25c9fd1653358c148f0", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/0dade995be754556af4dcbf8721d45cb3271f9b4", + "reference": "0dade995be754556af4dcbf8721d45cb3271f9b4", "shasum": "" }, "require": { @@ -8975,7 +8975,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.24.0" + "source": "https://github.com/twigphp/Twig/tree/v3.25.0" }, "funding": [ { @@ -8987,7 +8987,7 @@ "type": "tidelift" } ], - "time": "2026-03-17T21:31:11+00:00" + "time": "2026-05-17T07:41:26+00:00" }, { "name": "willdurand/negotiation", @@ -9412,16 +9412,16 @@ }, { "name": "ergebnis/composer-normalize", - "version": "2.51.0", + "version": "2.52.0", "source": { "type": "git", "url": "https://github.com/ergebnis/composer-normalize.git", - "reference": "36fb17dce18579ccab50f71b411a32ed55e6d4bc" + "reference": "988f83f5e51a42cdd2337e5fcd935432f8dfa33c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/composer-normalize/zipball/36fb17dce18579ccab50f71b411a32ed55e6d4bc", - "reference": "36fb17dce18579ccab50f71b411a32ed55e6d4bc", + "url": "https://api.github.com/repos/ergebnis/composer-normalize/zipball/988f83f5e51a42cdd2337e5fcd935432f8dfa33c", + "reference": "988f83f5e51a42cdd2337e5fcd935432f8dfa33c", "shasum": "" }, "require": { @@ -9435,27 +9435,27 @@ "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { - "composer/composer": "^2.9.4", + "composer/composer": "^2.9.8", "ergebnis/license": "^2.7.0", - "ergebnis/php-cs-fixer-config": "^6.61.1", + "ergebnis/php-cs-fixer-config": "^6.62.1", "ergebnis/phpstan-rules": "^2.13.1", "ergebnis/phpunit-slow-test-detector": "^2.24.0", "ergebnis/rector-rules": "^1.18.1", "fakerphp/faker": "^1.24.1", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.47", + "phpstan/phpstan": "^2.1.54", "phpstan/phpstan-deprecation-rules": "^2.0.4", "phpstan/phpstan-phpunit": "^2.0.16", - "phpstan/phpstan-strict-rules": "^2.0.10", + "phpstan/phpstan-strict-rules": "^2.0.11", "phpunit/phpunit": "^9.6.33", - "rector/rector": "^2.4.1", + "rector/rector": "^2.4.3", "symfony/filesystem": "^5.4.41" }, "type": "composer-plugin", "extra": { "class": "Ergebnis\\Composer\\Normalize\\NormalizePlugin", "branch-alias": { - "dev-main": "2.51-dev" + "dev-main": "2.52-dev" }, "plugin-optional": true, "composer-normalize": { @@ -9492,7 +9492,7 @@ "security": "https://github.com/ergebnis/composer-normalize/blob/main/.github/SECURITY.md", "source": "https://github.com/ergebnis/composer-normalize" }, - "time": "2026-04-14T11:17:04+00:00" + "time": "2026-05-15T15:39:24+00:00" }, { "name": "ergebnis/json", @@ -10049,16 +10049,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.95.1", + "version": "v3.95.2", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "a9727678fbd12997f1d9de8f4a37824ed9df1065" + "reference": "a28d88a5e172b27e78d0816992b15a9df3da20f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a9727678fbd12997f1d9de8f4a37824ed9df1065", - "reference": "a9727678fbd12997f1d9de8f4a37824ed9df1065", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a28d88a5e172b27e78d0816992b15a9df3da20f1", + "reference": "a28d88a5e172b27e78d0816992b15a9df3da20f1", "shasum": "" }, "require": { @@ -10090,8 +10090,8 @@ "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" }, "require-dev": { - "facile-it/paraunit": "^1.3.1 || ^2.8.0", - "infection/infection": "^0.32.6", + "facile-it/paraunit": "^1.3.1 || ^2.11.0", + "infection/infection": "^0.32.7", "justinrainbow/json-schema": "^6.8.0", "keradus/cli-executor": "^2.3", "mikey179/vfsstream": "^1.6.12", @@ -10142,7 +10142,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.1" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.2" }, "funding": [ { @@ -10150,7 +10150,7 @@ "type": "github" } ], - "time": "2026-04-12T17:00:09+00:00" + "time": "2026-05-15T09:20:44+00:00" }, { "name": "hautelook/alice-bundle", @@ -10812,11 +10812,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.54", + "version": "2.1.55", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", - "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9eaac3826ed5e9b8427350a43cac825eeca3f566", + "reference": "9eaac3826ed5e9b8427350a43cac825eeca3f566", "shasum": "" }, "require": { @@ -10861,7 +10861,7 @@ "type": "github" } ], - "time": "2026-04-29T13:31:09+00:00" + "time": "2026-05-18T11:57:34+00:00" }, { "name": "phpstan/phpstan-doctrine", @@ -10998,16 +10998,16 @@ }, { "name": "phpstan/phpstan-symfony", - "version": "2.0.17", + "version": "2.0.18", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-symfony.git", - "reference": "fdd0cb5f08d1980c612d6f259d825ea644ed03f4" + "reference": "a12176b639dec54e8bfd0a5ebf5fc36ffe003b5d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/fdd0cb5f08d1980c612d6f259d825ea644ed03f4", - "reference": "fdd0cb5f08d1980c612d6f259d825ea644ed03f4", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/a12176b639dec54e8bfd0a5ebf5fc36ffe003b5d", + "reference": "a12176b639dec54e8bfd0a5ebf5fc36ffe003b5d", "shasum": "" }, "require": { @@ -11066,22 +11066,22 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-symfony/issues", - "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.17" + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.18" }, - "time": "2026-05-10T08:14:07+00:00" + "time": "2026-05-18T14:51:49+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "14.1.8", + "version": "14.1.9", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "031856c28c060e1c1d1fc94d256e3ffbe4230c91" + "reference": "655533a65696bbc4231cd8027af150dadc40ec88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/031856c28c060e1c1d1fc94d256e3ffbe4230c91", - "reference": "031856c28c060e1c1d1fc94d256e3ffbe4230c91", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/655533a65696bbc4231cd8027af150dadc40ec88", + "reference": "655533a65696bbc4231cd8027af150dadc40ec88", "shasum": "" }, "require": { @@ -11138,7 +11138,7 @@ "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.1.8" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.1.9" }, "funding": [ { @@ -11158,7 +11158,7 @@ "type": "tidelift" } ], - "time": "2026-05-09T12:06:52+00:00" + "time": "2026-05-16T05:16:14+00:00" }, { "name": "phpunit/php-file-iterator", @@ -11455,16 +11455,16 @@ }, { "name": "phpunit/phpunit", - "version": "13.1.8", + "version": "13.1.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "f49a2b5e51ffb33421745368cc099cf66830d71b" + "reference": "38959098d3c10660a189afaa35a94290c1de67bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f49a2b5e51ffb33421745368cc099cf66830d71b", - "reference": "f49a2b5e51ffb33421745368cc099cf66830d71b", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/38959098d3c10660a189afaa35a94290c1de67bb", + "reference": "38959098d3c10660a189afaa35a94290c1de67bb", "shasum": "" }, "require": { @@ -11478,14 +11478,14 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.4.1", - "phpunit/php-code-coverage": "^14.1.6", + "phpunit/php-code-coverage": "^14.1.8", "phpunit/php-file-iterator": "^7.0.0", "phpunit/php-invoker": "^7.0.0", "phpunit/php-text-template": "^6.0.0", "phpunit/php-timer": "^9.0.0", "sebastian/cli-parser": "^5.0.0", - "sebastian/comparator": "^8.1.2", - "sebastian/diff": "^8.1.0", + "sebastian/comparator": "^8.1.3", + "sebastian/diff": "^8.3.0", "sebastian/environment": "^9.3.0", "sebastian/exporter": "^8.0.2", "sebastian/git-state": "^1.0", @@ -11534,7 +11534,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/13.1.8" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.1.10" }, "funding": [ { @@ -11542,7 +11542,7 @@ "type": "other" } ], - "time": "2026-05-01T04:22:45+00:00" + "time": "2026-05-15T08:03:56+00:00" }, { "name": "react/cache", @@ -12072,16 +12072,16 @@ }, { "name": "rector/rector", - "version": "2.4.2", + "version": "2.4.3", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "e645b6463c6a88ea5b44b17d3387d35a912c7946" + "reference": "891824c6c59f02a56a5dd58ea8edc44e6c0ece29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/e645b6463c6a88ea5b44b17d3387d35a912c7946", - "reference": "e645b6463c6a88ea5b44b17d3387d35a912c7946", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/891824c6c59f02a56a5dd58ea8edc44e6c0ece29", + "reference": "891824c6c59f02a56a5dd58ea8edc44e6c0ece29", "shasum": "" }, "require": { @@ -12120,7 +12120,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.4.2" + "source": "https://github.com/rectorphp/rector/tree/2.4.3" }, "funding": [ { @@ -12128,7 +12128,7 @@ "type": "github" } ], - "time": "2026-04-16T13:07:34+00:00" + "time": "2026-05-12T11:17:24+00:00" }, { "name": "sebastian/cli-parser", @@ -12201,23 +12201,23 @@ }, { "name": "sebastian/comparator", - "version": "8.1.2", + "version": "8.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "b3d09f4360ad97dcad8f82d1c047ad16ff38b7e1" + "reference": "1edd557042bf4ff9978ec125d8131b147d5c8224" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/b3d09f4360ad97dcad8f82d1c047ad16ff38b7e1", - "reference": "b3d09f4360ad97dcad8f82d1c047ad16ff38b7e1", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/1edd557042bf4ff9978ec125d8131b147d5c8224", + "reference": "1edd557042bf4ff9978ec125d8131b147d5c8224", "shasum": "" }, "require": { "ext-dom": "*", "ext-mbstring": "*", "php": ">=8.4", - "sebastian/diff": "^8.1", + "sebastian/diff": "^8.3", "sebastian/exporter": "^8.0" }, "require-dev": { @@ -12269,7 +12269,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/8.1.2" + "source": "https://github.com/sebastianbergmann/comparator/tree/8.1.4" }, "funding": [ { @@ -12289,7 +12289,7 @@ "type": "tidelift" } ], - "time": "2026-04-14T08:24:42+00:00" + "time": "2026-05-15T08:30:51+00:00" }, { "name": "sebastian/complexity", @@ -12363,16 +12363,16 @@ }, { "name": "sebastian/diff", - "version": "8.1.0", + "version": "8.3.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "9c957d730257f49c873f3761674559bd90098a7d" + "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/9c957d730257f49c873f3761674559bd90098a7d", - "reference": "9c957d730257f49c873f3761674559bd90098a7d", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b36d33b6e796513de7cb7df053afb3f55eefcd47", + "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47", "shasum": "" }, "require": { @@ -12385,7 +12385,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.1-dev" + "dev-main": "8.3-dev" } }, "autoload": { @@ -12418,7 +12418,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/8.1.0" + "source": "https://github.com/sebastianbergmann/diff/tree/8.3.0" }, "funding": [ { @@ -12438,7 +12438,7 @@ "type": "tidelift" } ], - "time": "2026-04-05T12:02:33+00:00" + "time": "2026-05-15T04:58:09+00:00" }, { "name": "sebastian/environment", @@ -13629,16 +13629,16 @@ }, { "name": "symfony/process", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc" + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", - "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "url": "https://api.github.com/repos/symfony/process/zipball/26d89e459f037d2873300605d0a07e7a8ef84db0", + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0", "shasum": "" }, "require": { @@ -13670,7 +13670,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.8" + "source": "https://github.com/symfony/process/tree/v8.0.11" }, "funding": [ { @@ -13690,20 +13690,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-11T16:56:32+00:00" }, { "name": "symfony/web-profiler-bundle", - "version": "v8.0.9", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "5c3d84efc47982dcce766092ba63d4435ed9f11e" + "reference": "5b80ded3fc1c76c6587be3f7b74bd0f8d9e6d747" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/5c3d84efc47982dcce766092ba63d4435ed9f11e", - "reference": "5c3d84efc47982dcce766092ba63d4435ed9f11e", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/5b80ded3fc1c76c6587be3f7b74bd0f8d9e6d747", + "reference": "5b80ded3fc1c76c6587be3f7b74bd0f8d9e6d747", "shasum": "" }, "require": { @@ -13755,7 +13755,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v8.0.9" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v8.0.11" }, "funding": [ { @@ -13775,7 +13775,7 @@ "type": "tidelift" } ], - "time": "2026-04-29T15:02:55+00:00" + "time": "2026-05-11T13:06:45+00:00" }, { "name": "theofidry/alice-data-fixtures", diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index a62be68..68c9b7e 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -21,6 +21,9 @@ doctrine: dir: '%kernel.project_dir%/src/Entity' prefix: 'App\Entity' alias: App + dql: + numeric_functions: + SEMVER_NUMERIC: App\Doctrine\Functions\SemverNumeric controller_resolver: auto_mapping: false diff --git a/src/Admin/SemverSort.php b/src/Admin/SemverSort.php new file mode 100644 index 0000000..3276d33 --- /dev/null +++ b/src/Admin/SemverSort.php @@ -0,0 +1,45 @@ +getDQLPart('orderBy'); + if ([] === $orderByParts) { + return; + } + + $alias = (string) current($qb->getRootAliases()); + $needles = []; + foreach ($properties as $property) { + $needles[$alias.'.'.$property] = 'SEMVER_NUMERIC('.$alias.'.'.$property.')'; + } + + $qb->resetDQLPart('orderBy'); + foreach ($orderByParts as $orderBy) { + foreach ($orderBy->getParts() as $part) { + if (preg_match('/^(.+?)\s+(ASC|DESC)$/i', $part, $m)) { + [$expr, $dir] = [$m[1], strtoupper($m[2])]; + } else { + [$expr, $dir] = [$part, 'ASC']; + } + + $expr = $needles[$expr] ?? $expr; + $qb->addOrderBy($expr, $dir); + } + } + } +} diff --git a/src/Controller/Admin/DockerImageTagCrudController.php b/src/Controller/Admin/DockerImageTagCrudController.php index 49f7855..068318d 100644 --- a/src/Controller/Admin/DockerImageTagCrudController.php +++ b/src/Controller/Admin/DockerImageTagCrudController.php @@ -6,6 +6,8 @@ use App\Admin\Field\VersionField; use App\Entity\DockerImageTag; +use App\Form\Type\Admin\SemverFilter; +use App\Trait\SemverSortableCrudControllerTrait; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; @@ -16,6 +18,8 @@ class DockerImageTagCrudController extends AbstractCrudController { + use SemverSortableCrudControllerTrait; + public static function getEntityFqcn(): string { return DockerImageTag::class; @@ -53,7 +57,13 @@ public function configureFilters(Filters $filters): Filters { return $filters ->add('dockerImage') - ->add('tag') + ->add(SemverFilter::new('tag')) ; } + + #[\Override] + protected function semverSortedProperties(): array + { + return ['tag']; + } } diff --git a/src/Controller/Admin/DomainCrudController.php b/src/Controller/Admin/DomainCrudController.php index 060e8ef..d1a98ab 100644 --- a/src/Controller/Admin/DomainCrudController.php +++ b/src/Controller/Admin/DomainCrudController.php @@ -8,6 +8,7 @@ use App\Admin\Field\ServerTypeField; use App\Admin\Field\SiteTypeField; use App\Entity\Domain; +use App\Form\Type\Admin\ServerTypeFilter; use App\Trait\ExportCrudControllerTrait; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; @@ -52,7 +53,7 @@ public function configureFields(string $pageName): iterable yield DomainField::new('address')->setColumns(12); yield SiteTypeField::new('site.type')->hideOnIndex(); yield AssociationField::new('site')->hideOnIndex(); - yield ServerTypeField::new('server.type')->setLabel('Type'); + yield ServerTypeField::new('server.type')->setLabel('Type')->setSortable(true); yield AssociationField::new('server'); yield AssociationField::new('detectionResult')->hideOnIndex(); yield DateTimeField::new('createdAt')->hideOnIndex(); @@ -66,6 +67,7 @@ public function configureFilters(Filters $filters): Filters ->add('address') ->add('site') ->add('server') + ->add(ServerTypeFilter::new('server.type', 'Server type')) ; } } diff --git a/src/Controller/Admin/GitTagCrudController.php b/src/Controller/Admin/GitTagCrudController.php index eceec7e..f715f1e 100644 --- a/src/Controller/Admin/GitTagCrudController.php +++ b/src/Controller/Admin/GitTagCrudController.php @@ -6,6 +6,8 @@ use App\Admin\Field\VersionField; use App\Entity\GitTag; +use App\Form\Type\Admin\SemverFilter; +use App\Trait\SemverSortableCrudControllerTrait; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; @@ -15,6 +17,8 @@ class GitTagCrudController extends AbstractCrudController { + use SemverSortableCrudControllerTrait; + public static function getEntityFqcn(): string { return GitTag::class; @@ -54,7 +58,13 @@ public function configureFilters(Filters $filters): Filters { return $filters ->add('repo') - ->add('tag') + ->add(SemverFilter::new('tag')) ; } + + #[\Override] + protected function semverSortedProperties(): array + { + return ['tag']; + } } diff --git a/src/Controller/Admin/InstallationCrudController.php b/src/Controller/Admin/InstallationCrudController.php index e78a4cf..cad1a95 100644 --- a/src/Controller/Admin/InstallationCrudController.php +++ b/src/Controller/Admin/InstallationCrudController.php @@ -11,7 +11,10 @@ use App\Admin\Field\VersionField; use App\Entity\Installation; use App\Form\Type\Admin\FrameworkFilter; +use App\Form\Type\Admin\SemverFilter; +use App\Form\Type\Admin\ServerTypeFilter; use App\Trait\ExportCrudControllerTrait; +use App\Trait\SemverSortableCrudControllerTrait; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; @@ -25,6 +28,7 @@ class InstallationCrudController extends AbstractCrudController { use ExportCrudControllerTrait; + use SemverSortableCrudControllerTrait; public static function getEntityFqcn(): string { @@ -65,7 +69,7 @@ public function configureFields(string $pageName): iterable yield CodeEditorField::new('gitChanges')->hideOnIndex(); yield AssociationField::new('sites')->hideOnIndex(); yield RootDirField::new('rootDir')->setColumns(12); - yield ServerTypeField::new('server.type')->setLabel('Type'); + yield ServerTypeField::new('server.type')->setLabel('Type')->setSortable(true); yield AssociationField::new('server'); yield AssociationField::new('detectionResult')->hideOnIndex(); yield DateTimeField::new('createdAt')->hideOnIndex(); @@ -77,13 +81,20 @@ public function configureFilters(Filters $filters): Filters { return $filters ->add(FrameworkFilter::new('type')) - ->add('frameworkVersion') + ->add(SemverFilter::new('frameworkVersion', 'ver.')) ->add('lts') ->add('eol') - ->add('composerVersion') + ->add(SemverFilter::new('composerVersion', 'Comp.')) ->add('rootDir') ->add('server') + ->add(ServerTypeFilter::new('server.type', 'Server type')) // ->add(SystemFilter::new('system')->mapped(false)) ; } + + #[\Override] + protected function semverSortedProperties(): array + { + return ['frameworkVersion', 'composerVersion']; + } } diff --git a/src/Controller/Admin/ModuleVersionCrudController.php b/src/Controller/Admin/ModuleVersionCrudController.php index 1d3f3e5..7db17aa 100644 --- a/src/Controller/Admin/ModuleVersionCrudController.php +++ b/src/Controller/Admin/ModuleVersionCrudController.php @@ -6,6 +6,8 @@ use App\Admin\Field\VersionField; use App\Entity\ModuleVersion; +use App\Form\Type\Admin\SemverFilter; +use App\Trait\SemverSortableCrudControllerTrait; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; @@ -15,6 +17,8 @@ class ModuleVersionCrudController extends AbstractCrudController { + use SemverSortableCrudControllerTrait; + public static function getEntityFqcn(): string { return ModuleVersion::class; @@ -51,7 +55,13 @@ public function configureFilters(Filters $filters): Filters { return $filters ->add('module') - ->add('version') + ->add(SemverFilter::new('version')) ; } + + #[\Override] + protected function semverSortedProperties(): array + { + return ['version']; + } } diff --git a/src/Controller/Admin/PackageVersionCrudController.php b/src/Controller/Admin/PackageVersionCrudController.php index a015c2b..68f521f 100644 --- a/src/Controller/Admin/PackageVersionCrudController.php +++ b/src/Controller/Admin/PackageVersionCrudController.php @@ -8,6 +8,8 @@ use App\Admin\Field\LatestStatusField; use App\Admin\Field\VersionField; use App\Entity\PackageVersion; +use App\Form\Type\Admin\SemverFilter; +use App\Trait\SemverSortableCrudControllerTrait; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; @@ -19,6 +21,8 @@ class PackageVersionCrudController extends AbstractCrudController { + use SemverSortableCrudControllerTrait; + public static function getEntityFqcn(): string { return PackageVersion::class; @@ -63,9 +67,15 @@ public function configureFilters(Filters $filters): Filters { return $filters ->add('package') - ->add('version') - ->add('latest') + ->add(SemverFilter::new('version')) + ->add(SemverFilter::new('latest')) ->add('latestStatus') ; } + + #[\Override] + protected function semverSortedProperties(): array + { + return ['version', 'latest']; + } } diff --git a/src/Controller/Admin/SiteCrudController.php b/src/Controller/Admin/SiteCrudController.php index 3380dab..4d49bb4 100644 --- a/src/Controller/Admin/SiteCrudController.php +++ b/src/Controller/Admin/SiteCrudController.php @@ -12,7 +12,10 @@ use App\Admin\Field\SiteTypeField; use App\Admin\Field\VersionField; use App\Entity\Site; +use App\Form\Type\Admin\SemverFilter; +use App\Form\Type\Admin\ServerTypeFilter; use App\Trait\ExportCrudControllerTrait; +use App\Trait\SemverSortableCrudControllerTrait; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; @@ -24,6 +27,7 @@ class SiteCrudController extends AbstractCrudController { use ExportCrudControllerTrait; + use SemverSortableCrudControllerTrait; public function __construct() { @@ -64,7 +68,7 @@ public function configureFields(string $pageName): iterable yield RootDirField::new('rootDir')->setColumns(12)->hideOnIndex(); yield VersionField::new('phpVersion')->setLabel('PHP'); yield AssociationField::new('installation')->hideOnIndex(); - yield ServerTypeField::new('server.type')->setLabel('Type'); + yield ServerTypeField::new('server.type')->setLabel('Type')->setSortable(true); yield AssociationField::new('server'); yield AssociationField::new('detectionResult')->hideOnIndex(); yield DateTimeField::new('createdAt')->hideOnIndex(); @@ -76,7 +80,14 @@ public function configureFilters(Filters $filters): Filters return $filters ->add('primaryDomain') ->add('configFilePath') - ->add('phpVersion') - ->add('server'); + ->add(SemverFilter::new('phpVersion', 'PHP')) + ->add('server') + ->add(ServerTypeFilter::new('server.type', 'Server type')); + } + + #[\Override] + protected function semverSortedProperties(): array + { + return ['phpVersion']; } } diff --git a/src/Doctrine/Functions/SemverNumeric.php b/src/Doctrine/Functions/SemverNumeric.php new file mode 100644 index 0000000..50b06e4 --- /dev/null +++ b/src/Doctrine/Functions/SemverNumeric.php @@ -0,0 +1,71 @@ +match(TokenType::T_IDENTIFIER); + $parser->match(TokenType::T_OPEN_PARENTHESIS); + $this->versionExpression = $parser->StringPrimary(); + $parser->match(TokenType::T_CLOSE_PARENTHESIS); + } + + #[\Override] + public function getSql(SqlWalker $sqlWalker): string + { + $expr = $this->versionExpression->dispatch($sqlWalker); + // Strip an optional leading "v" or "V" so "v5.5.40" parses identically to "5.5.40". + $stripped = sprintf("TRIM(LEADING 'v' FROM TRIM(LEADING 'V' FROM %s))", $expr); + $padded = sprintf("CONCAT(%s, '.0.0.0')", $stripped); + $segment = static fn (int $n): string => sprintf( + "CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(%s, '.', %d), '.', -1) AS UNSIGNED)", + $padded, + $n, + ); + + // Return NULL for anything that isn't a dotted-number sequence so non-semver + // rows ("?", "unknown", "main", "release-1.0", ...) get NULL comparisons + // and are excluded from the result set regardless of operator. + return sprintf( + "(CASE WHEN %s REGEXP '^[vV]?[0-9]+(\\\\.[0-9]+){0,3}\$' " + .'THEN (%s * 1000000000000 + %s * 100000000 + %s * 10000 + %s) ' + .'ELSE NULL END)', + $expr, + $segment(1), + $segment(2), + $segment(3), + $segment(4), + ); + } +} diff --git a/src/Form/Type/Admin/SemverFilter.php b/src/Form/Type/Admin/SemverFilter.php new file mode 100644 index 0000000..2ffe120 --- /dev/null +++ b/src/Form/Type/Admin/SemverFilter.php @@ -0,0 +1,148 @@ +setFilterFqcn(self::class) + ->setProperty($propertyName) + ->setLabel($label) + ->setFormType(SemverFilterType::class); + } + + public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, ?FieldDto $fieldDto, EntityDto $entityDto): void + { + // EasyAdmin's FilterDataDto splits the compound form: getValue() returns + // the "value" field as a scalar, getComparison() returns the operator, + // and getValue2() returns the optional second value (used for between). + $value = trim((string) $filterDataDto->getValue()); + $value2 = trim((string) $filterDataDto->getValue2()); + $comparison = $filterDataDto->getComparison(); + + if ('' === $value || !in_array($comparison, SemverFilterType::COMPARISON_CHOICES, true)) { + return; + } + + // When the user fills the upper-bound field together with a directional + // operator (>, >=, <, <=), treat the filter as a range. The operator's + // inclusivity carries over (>= / <= → inclusive, > / < → exclusive), + // so the natural reading "from X to Y" works regardless of which side + // the user picked. Range operators (between / between_exclusive) + // require both values and behave the same way. = and != are exact + // matches, so value2 is ignored. + $isExplicitRange = in_array($comparison, [SemverFilterType::COMPARISON_BETWEEN, SemverFilterType::COMPARISON_BETWEEN_EXCLUSIVE], true); + $autoRange = '' !== $value2 && in_array($comparison, [ + SemverFilterType::COMPARISON_GT, + SemverFilterType::COMPARISON_GTE, + SemverFilterType::COMPARISON_LT, + SemverFilterType::COMPARISON_LTE, + ], true); + $isRange = $isExplicitRange || $autoRange; + + if ($isExplicitRange && '' === $value2) { + return; + } + + try { + $parser = new VersionParser(); + $parser->normalize($value); + if ($isRange) { + $parser->normalize($value2); + } + } catch (\UnexpectedValueException) { + $queryBuilder->andWhere('1 = 0'); + + return; + } + + $alias = $filterDataDto->getEntityAlias(); + $property = $filterDataDto->getProperty(); + $parameter = $filterDataDto->getParameterName(); + + // The SEMVER_NUMERIC DQL function returns NULL for non-semver inputs, + // and a NULL comparison is always falsy in SQL — so non-semver rows + // (e.g. "?", "unknown", "main") are automatically excluded. We compute + // the user-input numeric in PHP and bind it as a single BIGINT rather + // than passing the string through SEMVER_NUMERIC again — otherwise + // Doctrine would emit the placeholder five times (the function + // references its argument five times) and PDO would complain about + // bound-variable count. + if ($isRange) { + $inclusive = in_array($comparison, [ + SemverFilterType::COMPARISON_BETWEEN, + SemverFilterType::COMPARISON_GTE, + SemverFilterType::COMPARISON_LTE, + ], true); + [$lowerOp, $upperOp] = $inclusive ? ['>=', '<='] : ['>', '<']; + + // Sort the two values numerically so the user can enter them in any + // order — "< 11.3.0" with value2 = "10.0.0" still produces a sane + // range, not an unsatisfiable WHERE. + $a = self::toSemverNumeric($value); + $b = self::toSemverNumeric($value2); + [$min, $max] = $a <= $b ? [$a, $b] : [$b, $a]; + + $queryBuilder + ->andWhere(sprintf( + 'SEMVER_NUMERIC(%1$s.%2$s) %3$s :%4$s_min AND SEMVER_NUMERIC(%1$s.%2$s) %5$s :%4$s_max', + $alias, + $property, + $lowerOp, + $parameter, + $upperOp, + )) + ->setParameter($parameter.'_min', $min) + ->setParameter($parameter.'_max', $max) + ; + + return; + } + + $queryBuilder + ->andWhere(sprintf( + 'SEMVER_NUMERIC(%1$s.%2$s) %3$s :%4$s', + $alias, + $property, + $comparison, + $parameter, + )) + ->setParameter($parameter, self::toSemverNumeric($value)) + ; + } + + /** + * Mirrors the SEMVER_NUMERIC DQL function: strips a leading v/V, splits + * on '.', pads to four segments with zeros, then packs major/minor/patch/ + * extra into a BIGINT with 10^4 of headroom per segment. Max value is + * ~10^16, comfortably below PHP_INT_MAX (~9.22·10^18). + */ + private static function toSemverNumeric(string $version): int + { + $segments = explode('.', ltrim($version, 'vV')); + $major = (int) $segments[0]; + $minor = (int) ($segments[1] ?? 0); + $patch = (int) ($segments[2] ?? 0); + $extra = (int) ($segments[3] ?? 0); + + return $major * 1_000_000_000_000 + + $minor * 100_000_000 + + $patch * 10_000 + + $extra; + } +} diff --git a/src/Form/Type/Admin/SemverFilterType.php b/src/Form/Type/Admin/SemverFilterType.php new file mode 100644 index 0000000..4f6006c --- /dev/null +++ b/src/Form/Type/Admin/SemverFilterType.php @@ -0,0 +1,61 @@ +'; + public const string COMPARISON_GTE = '>='; + public const string COMPARISON_LT = '<'; + public const string COMPARISON_LTE = '<='; + public const string COMPARISON_BETWEEN = 'between'; + public const string COMPARISON_BETWEEN_EXCLUSIVE = 'between_exclusive'; + + public const array COMPARISON_CHOICES = [ + '=' => self::COMPARISON_EQ, + '!=' => self::COMPARISON_NEQ, + '>' => self::COMPARISON_GT, + '>=' => self::COMPARISON_GTE, + '<' => self::COMPARISON_LT, + '<=' => self::COMPARISON_LTE, + 'between (inclusive)' => self::COMPARISON_BETWEEN, + 'between (exclusive)' => self::COMPARISON_BETWEEN_EXCLUSIVE, + ]; + + #[\Override] + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('comparison', ChoiceType::class, [ + 'choices' => self::COMPARISON_CHOICES, + 'choice_translation_domain' => false, + ]) + ->add('value', TextType::class, [ + 'required' => false, + 'attr' => ['placeholder' => 'e.g. 10.0.0'], + ]) + ->add('value2', TextType::class, [ + 'required' => false, + 'attr' => ['placeholder' => 'upper bound (optional, makes it a range)'], + ]) + ; + } + + #[\Override] + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + ]); + } +} diff --git a/src/Form/Type/Admin/ServerTypeFilter.php b/src/Form/Type/Admin/ServerTypeFilter.php index 502dd91..9c78a0d 100644 --- a/src/Form/Type/Admin/ServerTypeFilter.php +++ b/src/Form/Type/Admin/ServerTypeFilter.php @@ -27,7 +27,32 @@ public static function new(string $propertyName, false|string|TranslatableInterf public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, ?FieldDto $fieldDto, EntityDto $entityDto): void { - $queryBuilder->andWhere(sprintf('%s.%s = :type', $filterDataDto->getEntityAlias(), $filterDataDto->getProperty())) - ->setParameter('type', $filterDataDto->getValue()); + $rootAlias = $filterDataDto->getEntityAlias(); + $property = $filterDataDto->getProperty(); + $parameter = $filterDataDto->getParameterName(); + $value = $filterDataDto->getValue(); + + // EasyAdmin doesn't auto-join nested-property filters; if the + // property crosses a relation (e.g. "server.type"), join it + // ourselves and reference the joined alias. We reuse the plain + // relation name as the join alias to match EA's own sort-side + // auto-join (see EntityRepository::addOrderClause), so combining + // sort + filter on the same association produces a single JOIN. + // Background: https://github.com/EasyCorp/EasyAdminBundle/issues/4120. + if (str_contains($property, '.')) { + [$relation, $leafProperty] = explode('.', $property, 2); + if (!in_array($relation, $queryBuilder->getAllAliases(), true)) { + $queryBuilder->leftJoin($rootAlias.'.'.$relation, $relation); + } + $queryBuilder + ->andWhere(sprintf('%s.%s = :%s', $relation, $leafProperty, $parameter)) + ->setParameter($parameter, $value); + + return; + } + + $queryBuilder + ->andWhere(sprintf('%s.%s = :%s', $rootAlias, $property, $parameter)) + ->setParameter($parameter, $value); } } diff --git a/src/Trait/SemverSortableCrudControllerTrait.php b/src/Trait/SemverSortableCrudControllerTrait.php new file mode 100644 index 0000000..87b4b17 --- /dev/null +++ b/src/Trait/SemverSortableCrudControllerTrait.php @@ -0,0 +1,36 @@ + property names on the root entity whose + * ORDER BY should use semver order instead of + * lexicographic string order + */ + abstract protected function semverSortedProperties(): array; + + public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder + { + $qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters); + SemverSort::apply($qb, ...$this->semverSortedProperties()); + + return $qb; + } +} diff --git a/tests/Admin/SemverSortTest.php b/tests/Admin/SemverSortTest.php new file mode 100644 index 0000000..34a9801 --- /dev/null +++ b/tests/Admin/SemverSortTest.php @@ -0,0 +1,85 @@ +makeQb()->orderBy('entity.frameworkVersion', 'DESC'); + + SemverSort::apply($qb, 'frameworkVersion'); + + $parts = $qb->getDQLPart('orderBy'); + self::assertCount(1, $parts); + self::assertSame('SEMVER_NUMERIC(entity.frameworkVersion) DESC', $parts[0]->getParts()[0]); + } + + public function testKeepsDirectionAndAddsAscDefaultWhenMissing(): void + { + $qb = $this->makeQb()->orderBy('entity.frameworkVersion'); + + SemverSort::apply($qb, 'frameworkVersion'); + + self::assertSame('SEMVER_NUMERIC(entity.frameworkVersion) ASC', $qb->getDQLPart('orderBy')[0]->getParts()[0]); + } + + public function testLeavesNonMatchingPropertiesUntouched(): void + { + $qb = $this->makeQb() + ->orderBy('entity.rootDir', 'ASC') + ->addOrderBy('entity.frameworkVersion', 'DESC'); + + SemverSort::apply($qb, 'frameworkVersion'); + + $allParts = []; + foreach ($qb->getDQLPart('orderBy') as $orderBy) { + $allParts = [...$allParts, ...$orderBy->getParts()]; + } + self::assertSame(['entity.rootDir ASC', 'SEMVER_NUMERIC(entity.frameworkVersion) DESC'], $allParts); + } + + public function testRewritesMultipleListedProperties(): void + { + $qb = $this->makeQb() + ->orderBy('entity.frameworkVersion', 'DESC') + ->addOrderBy('entity.composerVersion', 'ASC'); + + SemverSort::apply($qb, 'frameworkVersion', 'composerVersion'); + + $allParts = []; + foreach ($qb->getDQLPart('orderBy') as $orderBy) { + $allParts = [...$allParts, ...$orderBy->getParts()]; + } + self::assertSame( + ['SEMVER_NUMERIC(entity.frameworkVersion) DESC', 'SEMVER_NUMERIC(entity.composerVersion) ASC'], + $allParts, + ); + } + + public function testIsNoopWhenNoOrderByPresent(): void + { + $qb = $this->makeQb(); + + SemverSort::apply($qb, 'frameworkVersion'); + + self::assertSame([], $qb->getDQLPart('orderBy')); + } + + private function makeQb(): \Doctrine\ORM\QueryBuilder + { + return $this->getEntityManager()->getRepository(Installation::class)->createQueryBuilder('entity'); + } + + private function getEntityManager(): EntityManagerInterface + { + return static::getContainer()->get('doctrine')->getManager(); + } +} diff --git a/tests/Form/Type/Admin/SemverFilterTest.php b/tests/Form/Type/Admin/SemverFilterTest.php new file mode 100644 index 0000000..57b1c80 --- /dev/null +++ b/tests/Form/Type/Admin/SemverFilterTest.php @@ -0,0 +1,197 @@ +getValue() to return the full + * compound-form array, but EasyAdmin's FilterDataDto already splits the + * compound form's children into getValue() (the value scalar) and + * getComparison() (the operator). With the bug, apply() returned early + * because is_array($scalar) is false, so no SEMVER_NUMERIC constraint + * was ever added to the query. + */ +class SemverFilterTest extends KernelTestCase +{ + #[DataProvider('semverFilterCases')] + public function testApplyAddsSemverConstraint(string $comparison, string $userValue, int $expectedNumeric): void + { + $qb = $this->makeInstallationQueryBuilder(); + + $this->apply($qb, $comparison, $userValue); + + $dql = $qb->getDQL(); + self::assertStringContainsString('SEMVER_NUMERIC(entity.frameworkVersion)', $dql, 'SEMVER_NUMERIC clause must be added on the column'); + self::assertStringContainsString(' '.$comparison.' :frameworkVersion_0', $dql, 'Must use the requested operator and a single bound parameter'); + self::assertSame($expectedNumeric, $qb->getParameter('frameworkVersion_0')?->getValue(), 'User input must be pre-computed to a BIGINT and bound once'); + } + + public function testApplyReturnsEarlyWhenValueIsEmpty(): void + { + $qb = $this->makeInstallationQueryBuilder(); + $dqlBefore = $qb->getDQL(); + + $this->apply($qb, '>', ''); + + self::assertSame($dqlBefore, $qb->getDQL(), 'No constraint should be added when value is empty'); + } + + public function testApplyShortCircuitsToNoResultsOnInvalidInput(): void + { + $qb = $this->makeInstallationQueryBuilder(); + + $this->apply($qb, '>', 'not-a-version'); + + self::assertStringContainsString('1 = 0', $qb->getDQL(), 'Invalid user input must produce a 0-row query'); + } + + public function testApplyHandlesExclusiveBetween(): void + { + $qb = $this->makeInstallationQueryBuilder(); + + $this->apply($qb, 'between_exclusive', '10', '11.3'); + + $dql = $qb->getDQL(); + self::assertStringContainsString('SEMVER_NUMERIC(entity.frameworkVersion) > :frameworkVersion_0_min', $dql, 'Exclusive range must use > on the lower bound'); + self::assertStringContainsString('SEMVER_NUMERIC(entity.frameworkVersion) < :frameworkVersion_0_max', $dql, 'Exclusive range must use < on the upper bound'); + self::assertSame(10_000_000_000_000, $qb->getParameter('frameworkVersion_0_min')?->getValue()); + self::assertSame(11_000_300_000_000, $qb->getParameter('frameworkVersion_0_max')?->getValue()); + } + + public function testApplyHandlesInclusiveBetween(): void + { + $qb = $this->makeInstallationQueryBuilder(); + + $this->apply($qb, 'between', '10', '11.3'); + + $dql = $qb->getDQL(); + self::assertStringContainsString('SEMVER_NUMERIC(entity.frameworkVersion) >= :frameworkVersion_0_min', $dql, 'Inclusive range must use >= on the lower bound'); + self::assertStringContainsString('SEMVER_NUMERIC(entity.frameworkVersion) <= :frameworkVersion_0_max', $dql, 'Inclusive range must use <= on the upper bound'); + } + + public function testApplyReturnsEarlyForRangeWithoutUpperBound(): void + { + $qb = $this->makeInstallationQueryBuilder(); + $dqlBefore = $qb->getDQL(); + + $this->apply($qb, 'between', '10', ''); + + self::assertSame($dqlBefore, $qb->getDQL(), 'Missing upper bound on a range filter must be a no-op'); + } + + public function testApplyShortCircuitsRangeOnInvalidUpperBound(): void + { + $qb = $this->makeInstallationQueryBuilder(); + + $this->apply($qb, 'between_exclusive', '10', 'oops'); + + self::assertStringContainsString('1 = 0', $qb->getDQL(), 'Invalid upper-bound input must produce a 0-row query'); + } + + /** + * Regression: `>= 11.0.0` with value2=11.2.0 must produce a range, + * not silently ignore the upper bound (which previously let 11.3.5 + * leak through). + */ + public function testApplyAutoPromotesGteWithValue2ToInclusiveRange(): void + { + $qb = $this->makeInstallationQueryBuilder(); + + $this->apply($qb, '>=', '11.0.0', '11.2.0'); + + $dql = $qb->getDQL(); + self::assertStringContainsString('SEMVER_NUMERIC(entity.frameworkVersion) >= :frameworkVersion_0_min', $dql); + self::assertStringContainsString('SEMVER_NUMERIC(entity.frameworkVersion) <= :frameworkVersion_0_max', $dql); + self::assertSame(11_000_000_000_000, $qb->getParameter('frameworkVersion_0_min')?->getValue()); + self::assertSame(11_000_200_000_000, $qb->getParameter('frameworkVersion_0_max')?->getValue()); + } + + public function testApplyAutoPromotesGtWithValue2ToExclusiveRange(): void + { + $qb = $this->makeInstallationQueryBuilder(); + + $this->apply($qb, '>', '11.0.0', '11.2.0'); + + $dql = $qb->getDQL(); + self::assertStringContainsString('SEMVER_NUMERIC(entity.frameworkVersion) > :frameworkVersion_0_min', $dql); + self::assertStringContainsString('SEMVER_NUMERIC(entity.frameworkVersion) < :frameworkVersion_0_max', $dql); + } + + /** + * Auto-promote sorts the two values so users can enter them in either + * order — "< 11.2.0" with value2 = "11.0.0" still means [11.0.0, 11.2.0]. + */ + public function testApplyAutoPromoteSortsValuesNumerically(): void + { + $qb = $this->makeInstallationQueryBuilder(); + + $this->apply($qb, '<', '11.2.0', '11.0.0'); + + self::assertSame(11_000_000_000_000, $qb->getParameter('frameworkVersion_0_min')?->getValue()); + self::assertSame(11_000_200_000_000, $qb->getParameter('frameworkVersion_0_max')?->getValue()); + } + + public function testApplyIgnoresValue2ForEqualsOperator(): void + { + $qb = $this->makeInstallationQueryBuilder(); + + $this->apply($qb, '=', '11.0.0', '11.2.0'); + + $dql = $qb->getDQL(); + self::assertStringNotContainsString('_min', $dql, '= operator with value2 must remain a single-value match'); + self::assertSame(11_000_000_000_000, $qb->getParameter('frameworkVersion_0')?->getValue()); + } + + /** + * @return iterable + */ + public static function semverFilterCases(): iterable + { + yield 'less-than-or-equal' => ['<=', '10.6.3', 10_000_600_030_000]; + yield 'greater-than' => ['>', '10.0.0', 10_000_000_000_000]; + yield 'equals' => ['=', '10.5.9', 10_000_500_090_000]; + yield 'v-prefix accepted' => ['<=', 'v5.5.40', 5_000_500_400_000]; + } + + private function apply(QueryBuilder $qb, string $comparison, string $userValue, string $userValue2 = ''): void + { + $filterDto = new FilterDto(); + $filterDto->setProperty('frameworkVersion'); + $filterDto->setFormType(SemverFilterType::class); + + SemverFilter::new('frameworkVersion')->apply( + $qb, + FilterDataDto::new(0, $filterDto, 'entity', [ + 'comparison' => $comparison, + 'value' => $userValue, + 'value2' => $userValue2, + ]), + null, + new EntityDto(Installation::class, $this->getEntityManager()->getClassMetadata(Installation::class)), + ); + } + + private function makeInstallationQueryBuilder(): QueryBuilder + { + return $this->getEntityManager()->getRepository(Installation::class)->createQueryBuilder('entity'); + } + + private function getEntityManager(): EntityManagerInterface + { + return static::getContainer()->get('doctrine')->getManager(); + } +} diff --git a/tests/Form/Type/Admin/ServerTypeFilterTest.php b/tests/Form/Type/Admin/ServerTypeFilterTest.php new file mode 100644 index 0000000..8efde63 --- /dev/null +++ b/tests/Form/Type/Admin/ServerTypeFilterTest.php @@ -0,0 +1,81 @@ +makeInstallationQueryBuilder(); + + $this->apply($qb, 'type', 'prod'); + + $dql = $qb->getDQL(); + self::assertStringContainsString('entity.type = :type_0', $dql); + self::assertStringNotContainsString('JOIN', $dql, 'A flat property must not trigger any JOIN'); + self::assertSame('prod', $qb->getParameter('type_0')?->getValue()); + } + + public function testApplyOnNestedPropertyJoinsTheRelation(): void + { + $qb = $this->makeInstallationQueryBuilder(); + + $this->apply($qb, 'server.type', 'stg'); + + $dql = $qb->getDQL(); + self::assertStringContainsString('LEFT JOIN entity.server server', $dql, 'Nested filter must auto-join the relation'); + self::assertStringContainsString('server.type = :server_type_0', $dql); + self::assertSame('stg', $qb->getParameter('server_type_0')?->getValue()); + } + + public function testApplyReusesExistingJoinAliasInsteadOfDuplicating(): void + { + $qb = $this->makeInstallationQueryBuilder() + ->leftJoin('entity.server', 'server'); + + $this->apply($qb, 'server.type', 'devops'); + + $dql = $qb->getDQL(); + self::assertSame(1, substr_count($dql, 'LEFT JOIN entity.server'), 'No duplicate join when alias already exists'); + self::assertStringContainsString('server.type = :server_type_0', $dql); + } + + private function apply(QueryBuilder $qb, string $property, string $value): void + { + $filterDto = new FilterDto(); + $filterDto->setProperty($property); + $filterDto->setFormType(ServerTypeFilterType::class); + + ServerTypeFilter::new($property)->apply( + $qb, + FilterDataDto::new(0, $filterDto, 'entity', [ + 'comparison' => '=', + 'value' => $value, + ]), + null, + new EntityDto(Installation::class, $this->getEntityManager()->getClassMetadata(Installation::class)), + ); + } + + private function makeInstallationQueryBuilder(): QueryBuilder + { + return $this->getEntityManager()->getRepository(Installation::class)->createQueryBuilder('entity'); + } + + private function getEntityManager(): EntityManagerInterface + { + return static::getContainer()->get('doctrine')->getManager(); + } +}