From 9bcda1af82c96fbfeb6a27905e2625428e01408c Mon Sep 17 00:00:00 2001 From: turegjorup Date: Mon, 18 May 2026 23:11:17 +0200 Subject: [PATCH 01/14] feat: add semver filter to admin version columns Add a reusable EasyAdmin filter for dotted-version columns that compares using semver order (=, !=, >, >=, <, <=) instead of the default lexicographic string comparison, so e.g. "10.5.9" correctly sorts above "9.5.1" and "v5.5.40" sorts identically to "5.5.40". Comparison runs in SQL via a new Doctrine DQL function SEMVER_NUMERIC(version) that strips an optional leading v/V, then packs major/minor/patch/extra into a sortable BIGINT using SUBSTRING_INDEX + CAST AS UNSIGNED. A REGEXP guard on the column excludes non-semver rows (?, unknown, "main", ...) from results when the filter is active. Wired into the version columns on Installation (frameworkVersion, composerVersion), PackageVersion (version, latest), ModuleVersion (version), Site (phpVersion), GitTag (tag) and DockerImageTag (tag). --- CHANGELOG.md | 8 +++ config/packages/doctrine.yaml | 3 + .../Admin/DockerImageTagCrudController.php | 3 +- src/Controller/Admin/GitTagCrudController.php | 3 +- .../Admin/InstallationCrudController.php | 5 +- .../Admin/ModuleVersionCrudController.php | 3 +- .../Admin/PackageVersionCrudController.php | 5 +- src/Controller/Admin/SiteCrudController.php | 3 +- src/Doctrine/Functions/SemverNumeric.php | 57 +++++++++++++++ src/Form/Type/Admin/SemverFilter.php | 70 +++++++++++++++++++ src/Form/Type/Admin/SemverFilterType.php | 53 ++++++++++++++ 11 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 src/Doctrine/Functions/SemverNumeric.php create mode 100644 src/Form/Type/Admin/SemverFilter.php create mode 100644 src/Form/Type/Admin/SemverFilterType.php diff --git a/CHANGELOG.md b/CHANGELOG.md index acd3eda2..74c54286 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Add semver-aware filter on every admin version column (Installation + `frameworkVersion` and `composerVersion`, PackageVersion `version` and + `latest`, ModuleVersion `version`, Site `phpVersion`, GitTag `tag`, + DockerImageTag `tag`) so the admin can filter with `=, !=, >, >=, <, <=` + using semver order instead of lexicographic string comparison. Values + with a leading `v`/`V` (e.g. `v5.5.40`) are accepted; non-semver values + are excluded from results when the filter is active. + ## [1.10.1] - 2026-05-11 - [#71](https://github.com/itk-dev/devops_itksites/pull/71) diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index a62be68e..68c9b7e8 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/Controller/Admin/DockerImageTagCrudController.php b/src/Controller/Admin/DockerImageTagCrudController.php index 49f78552..503d569f 100644 --- a/src/Controller/Admin/DockerImageTagCrudController.php +++ b/src/Controller/Admin/DockerImageTagCrudController.php @@ -6,6 +6,7 @@ use App\Admin\Field\VersionField; use App\Entity\DockerImageTag; +use App\Form\Type\Admin\SemverFilter; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; @@ -53,7 +54,7 @@ public function configureFilters(Filters $filters): Filters { return $filters ->add('dockerImage') - ->add('tag') + ->add(SemverFilter::new('tag')) ; } } diff --git a/src/Controller/Admin/GitTagCrudController.php b/src/Controller/Admin/GitTagCrudController.php index eceec7e1..ee601fac 100644 --- a/src/Controller/Admin/GitTagCrudController.php +++ b/src/Controller/Admin/GitTagCrudController.php @@ -6,6 +6,7 @@ use App\Admin\Field\VersionField; use App\Entity\GitTag; +use App\Form\Type\Admin\SemverFilter; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; @@ -54,7 +55,7 @@ public function configureFilters(Filters $filters): Filters { return $filters ->add('repo') - ->add('tag') + ->add(SemverFilter::new('tag')) ; } } diff --git a/src/Controller/Admin/InstallationCrudController.php b/src/Controller/Admin/InstallationCrudController.php index e78a4cf5..415a0665 100644 --- a/src/Controller/Admin/InstallationCrudController.php +++ b/src/Controller/Admin/InstallationCrudController.php @@ -11,6 +11,7 @@ use App\Admin\Field\VersionField; use App\Entity\Installation; use App\Form\Type\Admin\FrameworkFilter; +use App\Form\Type\Admin\SemverFilter; use App\Trait\ExportCrudControllerTrait; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; @@ -77,10 +78,10 @@ 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(SystemFilter::new('system')->mapped(false)) diff --git a/src/Controller/Admin/ModuleVersionCrudController.php b/src/Controller/Admin/ModuleVersionCrudController.php index 1d3f3e57..c7479f89 100644 --- a/src/Controller/Admin/ModuleVersionCrudController.php +++ b/src/Controller/Admin/ModuleVersionCrudController.php @@ -6,6 +6,7 @@ use App\Admin\Field\VersionField; use App\Entity\ModuleVersion; +use App\Form\Type\Admin\SemverFilter; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; @@ -51,7 +52,7 @@ public function configureFilters(Filters $filters): Filters { return $filters ->add('module') - ->add('version') + ->add(SemverFilter::new('version')) ; } } diff --git a/src/Controller/Admin/PackageVersionCrudController.php b/src/Controller/Admin/PackageVersionCrudController.php index a015c2b4..d5050172 100644 --- a/src/Controller/Admin/PackageVersionCrudController.php +++ b/src/Controller/Admin/PackageVersionCrudController.php @@ -8,6 +8,7 @@ use App\Admin\Field\LatestStatusField; use App\Admin\Field\VersionField; use App\Entity\PackageVersion; +use App\Form\Type\Admin\SemverFilter; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; @@ -63,8 +64,8 @@ public function configureFilters(Filters $filters): Filters { return $filters ->add('package') - ->add('version') - ->add('latest') + ->add(SemverFilter::new('version')) + ->add(SemverFilter::new('latest')) ->add('latestStatus') ; } diff --git a/src/Controller/Admin/SiteCrudController.php b/src/Controller/Admin/SiteCrudController.php index 3380dab1..535881d9 100644 --- a/src/Controller/Admin/SiteCrudController.php +++ b/src/Controller/Admin/SiteCrudController.php @@ -12,6 +12,7 @@ use App\Admin\Field\SiteTypeField; use App\Admin\Field\VersionField; use App\Entity\Site; +use App\Form\Type\Admin\SemverFilter; use App\Trait\ExportCrudControllerTrait; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; @@ -76,7 +77,7 @@ public function configureFilters(Filters $filters): Filters return $filters ->add('primaryDomain') ->add('configFilePath') - ->add('phpVersion') + ->add(SemverFilter::new('phpVersion', 'PHP')) ->add('server'); } } diff --git a/src/Doctrine/Functions/SemverNumeric.php b/src/Doctrine/Functions/SemverNumeric.php new file mode 100644 index 00000000..88418623 --- /dev/null +++ b/src/Doctrine/Functions/SemverNumeric.php @@ -0,0 +1,57 @@ +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 sprintf( + '(%s * 1000000000000000000 + %s * 1000000000000 + %s * 1000000 + %s)', + $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 00000000..a69efe97 --- /dev/null +++ b/src/Form/Type/Admin/SemverFilter.php @@ -0,0 +1,70 @@ +setFilterFqcn(self::class) + ->setProperty($propertyName) + ->setLabel($label) + ->setFormType(SemverFilterType::class); + } + + public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, ?FieldDto $fieldDto, EntityDto $entityDto): void + { + $data = $filterDataDto->getValue(); + if (!is_array($data)) { + return; + } + + $value = isset($data['value']) ? trim((string) $data['value']) : ''; + $comparison = $data['comparison'] ?? null; + + if ('' === $value || !is_string($comparison) || !isset(SemverFilterType::COMPARISON_CHOICES[$comparison])) { + return; + } + + try { + new VersionParser()->normalize($value); + } catch (\UnexpectedValueException) { + $queryBuilder->andWhere('1 = 0'); + + return; + } + + $alias = $filterDataDto->getEntityAlias(); + $property = $filterDataDto->getProperty(); + $parameter = $filterDataDto->getParameterName(); + + $queryBuilder + ->andWhere(sprintf('%1$s.%2$s REGEXP :%3$s_pattern', $alias, $property, $parameter)) + ->andWhere(sprintf( + 'SEMVER_NUMERIC(%1$s.%2$s) %3$s SEMVER_NUMERIC(:%4$s_value)', + $alias, + $property, + $comparison, + $parameter, + )) + ->setParameter($parameter.'_pattern', self::VERSION_REGEXP) + ->setParameter($parameter.'_value', $value) + ; + } +} diff --git a/src/Form/Type/Admin/SemverFilterType.php b/src/Form/Type/Admin/SemverFilterType.php new file mode 100644 index 00000000..87f22193 --- /dev/null +++ b/src/Form/Type/Admin/SemverFilterType.php @@ -0,0 +1,53 @@ +'; + public const string COMPARISON_GTE = '>='; + public const string COMPARISON_LT = '<'; + public const string COMPARISON_LTE = '<='; + + public const array COMPARISON_CHOICES = [ + '=' => self::COMPARISON_EQ, + '!=' => self::COMPARISON_NEQ, + '>' => self::COMPARISON_GT, + '>=' => self::COMPARISON_GTE, + '<' => self::COMPARISON_LT, + '<=' => self::COMPARISON_LTE, + ]; + + #[\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'], + ]) + ; + } + + #[\Override] + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + ]); + } +} From 6e3d6e5ef7a720dc1f11e6684b77e794eb979246 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Mon, 18 May 2026 23:30:11 +0200 Subject: [PATCH 02/14] fix: actually apply the semver filter Three independent bugs were hiding behind each other and meant the filter silently no-oped (matched everything). Browser test against ?filters[type]=drupal&filters[frameworkVersion][comparison]=<= &filters[frameworkVersion][value]=10.6.3 returned 11.x rows. 1. SemverFilter::apply() assumed FilterDataDto::getValue() returned the full compound-form array and did `if (!is_array($data)) return;`. In EasyAdmin 5 the DTO already splits compound forms into getValue() (the scalar) and getComparison() (the operator), so the early return fired and no constraint was added. Switched to the documented API. 2. Once the WHERE clause was added, the original draft used a DQL-level REGEXP keyword for the non-semver guard. DQL doesn't recognise REGEXP and Doctrine threw a syntax error. Moved the regex into SEMVER_NUMERIC itself: the function now wraps its arithmetic in CASE WHEN col REGEXP '...' THEN ... ELSE NULL END, so non-semver rows collapse to NULL and are excluded by any comparison. 3. Passing the user input through SEMVER_NUMERIC a second time emitted the argument-placeholder five times (the function references its argument once for the regex and four times for the segments), so PDO saw five `?` but only one bound value. Computed the numeric in PHP (toSemverNumeric) and bind it as a single BIGINT. Multipliers shrunk from 10^6 to 10^4 per segment so the precomputed value fits in PHP_INT_MAX. Adds a regression test against FilterDataDto's actual shape to keep all three from coming back. --- src/Doctrine/Functions/SemverNumeric.php | 24 ++++-- src/Form/Type/Admin/SemverFilter.php | 49 ++++++++--- tests/Form/Type/Admin/SemverFilterTest.php | 99 ++++++++++++++++++++++ 3 files changed, 153 insertions(+), 19 deletions(-) create mode 100644 tests/Form/Type/Admin/SemverFilterTest.php diff --git a/src/Doctrine/Functions/SemverNumeric.php b/src/Doctrine/Functions/SemverNumeric.php index 88418623..50b06e45 100644 --- a/src/Doctrine/Functions/SemverNumeric.php +++ b/src/Doctrine/Functions/SemverNumeric.php @@ -14,11 +14,19 @@ * SEMVER_NUMERIC(versionString) — MariaDB DQL function returning a sortable * BIGINT for a dotted-version string. Padding the string with ".0.0.0" lets * us treat shorter versions ("10", "10.5") as if all four segments were - * present. Each segment is given 10^6 of room, packed left-to-right, so - * "10.5.9" becomes 10·10^18 + 5·10^12 + 9·10^6 = 10005009000000. + * present. Each segment is given 10^4 of room (max 9999 per segment), + * packed left-to-right, so "10.5.9" becomes + * 10·10^12 + 5·10^8 + 9·10^4 = 10_000_500_090_000. * - * Non-numeric segments cast to 0 (with a MariaDB warning); callers should - * combine this with a REGEXP guard if they need to exclude such rows. + * The 10^4 cap keeps the maximum value (~10^16) well inside PHP's + * 64-bit PHP_INT_MAX (~9.22·10^18), so callers can mirror this arithmetic + * in PHP to bind a single BIGINT parameter rather than feeding the + * raw string back through the DQL function (which would expand the + * argument-placeholder several times — see SemverFilter::toSemverNumeric()). + * + * Non-semver inputs (anything that doesn't match the dotted-number shape) + * collapse to NULL via the outer CASE so they're excluded from any + * comparison. */ class SemverNumeric extends FunctionNode { @@ -46,8 +54,14 @@ public function getSql(SqlWalker $sqlWalker): string $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( - '(%s * 1000000000000000000 + %s * 1000000000000 + %s * 1000000 + %s)', + "(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), diff --git a/src/Form/Type/Admin/SemverFilter.php b/src/Form/Type/Admin/SemverFilter.php index a69efe97..bb61de4f 100644 --- a/src/Form/Type/Admin/SemverFilter.php +++ b/src/Form/Type/Admin/SemverFilter.php @@ -17,8 +17,6 @@ class SemverFilter implements FilterInterface { use FilterTrait; - private const string VERSION_REGEXP = '^[vV]?[0-9]+(\\.[0-9]+){0,3}$'; - public static function new(string $propertyName, false|string|TranslatableInterface|null $label = null): self { return new self() @@ -30,15 +28,12 @@ public static function new(string $propertyName, false|string|TranslatableInterf public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, ?FieldDto $fieldDto, EntityDto $entityDto): void { - $data = $filterDataDto->getValue(); - if (!is_array($data)) { - return; - } - - $value = isset($data['value']) ? trim((string) $data['value']) : ''; - $comparison = $data['comparison'] ?? null; + // EasyAdmin's FilterDataDto splits the compound form: getValue() returns + // the "value" field as a scalar, and getComparison() returns the operator. + $value = trim((string) $filterDataDto->getValue()); + $comparison = $filterDataDto->getComparison(); - if ('' === $value || !is_string($comparison) || !isset(SemverFilterType::COMPARISON_CHOICES[$comparison])) { + if ('' === $value || !isset(SemverFilterType::COMPARISON_CHOICES[$comparison])) { return; } @@ -54,17 +49,43 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, $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. $queryBuilder - ->andWhere(sprintf('%1$s.%2$s REGEXP :%3$s_pattern', $alias, $property, $parameter)) ->andWhere(sprintf( - 'SEMVER_NUMERIC(%1$s.%2$s) %3$s SEMVER_NUMERIC(:%4$s_value)', + 'SEMVER_NUMERIC(%1$s.%2$s) %3$s :%4$s', $alias, $property, $comparison, $parameter, )) - ->setParameter($parameter.'_pattern', self::VERSION_REGEXP) - ->setParameter($parameter.'_value', $value) + ->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/tests/Form/Type/Admin/SemverFilterTest.php b/tests/Form/Type/Admin/SemverFilterTest.php new file mode 100644 index 00000000..ac115184 --- /dev/null +++ b/tests/Form/Type/Admin/SemverFilterTest.php @@ -0,0 +1,99 @@ +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'); + } + + /** + * @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): 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, + ]), + 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(); + } +} From a2f371c1fb3958f752f45856669c50526dd66406 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Mon, 18 May 2026 23:38:51 +0200 Subject: [PATCH 03/14] feat: add inclusive/exclusive between operators to SemverFilter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two range operators so the admin can filter by a version interval in a single filter: * between (inclusive) → SEMVER_NUMERIC(col) >= a AND <= b * between (exclusive) → SEMVER_NUMERIC(col) > a AND < b The exclusive variant covers the common "greater than X, lower than Y" case directly (e.g. >10 AND <11.3 in one filter). SemverFilterType gains a `value2` text field used as the upper bound for the range operators (ignored otherwise). SemverFilter::apply() validates both ends through composer/semver and binds the precomputed BIGINTs as two parameters. Also fixes a latent bug in the COMPARISON_CHOICES validity check that was using values as keys; it happened to work for =/!=/etc. because the key and value were identical, but broke once between-style entries had distinct labels. --- CHANGELOG.md | 6 ++- src/Form/Type/Admin/SemverFilter.php | 39 ++++++++++++++++-- src/Form/Type/Admin/SemverFilterType.php | 8 ++++ tests/Form/Type/Admin/SemverFilterTest.php | 46 +++++++++++++++++++++- 4 files changed, 93 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74c54286..f60a413d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `frameworkVersion` and `composerVersion`, PackageVersion `version` and `latest`, ModuleVersion `version`, Site `phpVersion`, GitTag `tag`, DockerImageTag `tag`) so the admin can filter with `=, !=, >, >=, <, <=` - using semver order instead of lexicographic string comparison. Values - with a leading `v`/`V` (e.g. `v5.5.40`) are accepted; non-semver values + or by an inclusive/exclusive range (`between (inclusive)` / `between + (exclusive)`) using semver order instead of lexicographic string + comparison. Values with a leading `v`/`V` (e.g. `v5.5.40`) are accepted; + non-semver values are excluded from results when the filter is active. ## [1.10.1] - 2026-05-11 diff --git a/src/Form/Type/Admin/SemverFilter.php b/src/Form/Type/Admin/SemverFilter.php index bb61de4f..b8f092df 100644 --- a/src/Form/Type/Admin/SemverFilter.php +++ b/src/Form/Type/Admin/SemverFilter.php @@ -29,16 +29,28 @@ public static function new(string $propertyName, false|string|TranslatableInterf 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, and getComparison() returns the operator. + // 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 || !isset(SemverFilterType::COMPARISON_CHOICES[$comparison])) { + if ('' === $value || !in_array($comparison, SemverFilterType::COMPARISON_CHOICES, true)) { + return; + } + + $isRange = SemverFilterType::COMPARISON_BETWEEN === $comparison + || SemverFilterType::COMPARISON_BETWEEN_EXCLUSIVE === $comparison; + if ($isRange && '' === $value2) { return; } try { - new VersionParser()->normalize($value); + $parser = new VersionParser(); + $parser->normalize($value); + if ($isRange) { + $parser->normalize($value2); + } } catch (\UnexpectedValueException) { $queryBuilder->andWhere('1 = 0'); @@ -57,6 +69,27 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, // Doctrine would emit the placeholder five times (the function // references its argument five times) and PDO would complain about // bound-variable count. + if ($isRange) { + [$lowerOp, $upperOp] = SemverFilterType::COMPARISON_BETWEEN === $comparison + ? ['>=', '<='] + : ['>', '<']; + + $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', self::toSemverNumeric($value)) + ->setParameter($parameter.'_max', self::toSemverNumeric($value2)) + ; + + return; + } + $queryBuilder ->andWhere(sprintf( 'SEMVER_NUMERIC(%1$s.%2$s) %3$s :%4$s', diff --git a/src/Form/Type/Admin/SemverFilterType.php b/src/Form/Type/Admin/SemverFilterType.php index 87f22193..939df161 100644 --- a/src/Form/Type/Admin/SemverFilterType.php +++ b/src/Form/Type/Admin/SemverFilterType.php @@ -18,6 +18,8 @@ class SemverFilterType extends AbstractType 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, @@ -26,6 +28,8 @@ class SemverFilterType extends AbstractType '>=' => self::COMPARISON_GTE, '<' => self::COMPARISON_LT, '<=' => self::COMPARISON_LTE, + 'between (inclusive)' => self::COMPARISON_BETWEEN, + 'between (exclusive)' => self::COMPARISON_BETWEEN_EXCLUSIVE, ]; #[\Override] @@ -40,6 +44,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'attr' => ['placeholder' => 'e.g. 10.0.0'], ]) + ->add('value2', TextType::class, [ + 'required' => false, + 'attr' => ['placeholder' => 'upper bound (when between)'], + ]) ; } diff --git a/tests/Form/Type/Admin/SemverFilterTest.php b/tests/Form/Type/Admin/SemverFilterTest.php index ac115184..e95c721c 100644 --- a/tests/Form/Type/Admin/SemverFilterTest.php +++ b/tests/Form/Type/Admin/SemverFilterTest.php @@ -59,6 +59,49 @@ public function testApplyShortCircuitsToNoResultsOnInvalidInput(): void 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'); + } + /** * @return iterable */ @@ -70,7 +113,7 @@ public static function semverFilterCases(): iterable yield 'v-prefix accepted' => ['<=', 'v5.5.40', 5_000_500_400_000]; } - private function apply(QueryBuilder $qb, string $comparison, string $userValue): void + private function apply(QueryBuilder $qb, string $comparison, string $userValue, string $userValue2 = ''): void { $filterDto = new FilterDto(); $filterDto->setProperty('frameworkVersion'); @@ -81,6 +124,7 @@ private function apply(QueryBuilder $qb, string $comparison, string $userValue): FilterDataDto::new(0, $filterDto, 'entity', [ 'comparison' => $comparison, 'value' => $userValue, + 'value2' => $userValue2, ]), null, new EntityDto(Installation::class, $this->getEntityManager()->getClassMetadata(Installation::class)), From 80aa6f235a301740207edb3d2765b286f588f6d9 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Mon, 18 May 2026 23:49:14 +0200 Subject: [PATCH 04/14] feat: sort admin version columns in semver order Clicking the column header on a version column (ver., Comp., PHP, Tag, version/latest) now sorts in semver order instead of lexicographic, so 10.5.9 correctly comes above 9.5.1 and 11.2.10 above 11.2.8. Implemented with a tiny SemverSort helper that walks the QueryBuilder's existing orderBy parts and rewrites "entity. " into "SEMVER_NUMERIC(entity.) " for the listed properties. Each affected CRUD controller overrides createIndexQueryBuilder() to invoke it for its version columns: * InstallationCrudController: frameworkVersion, composerVersion * PackageVersionCrudController: version, latest * ModuleVersionCrudController: version * SiteCrudController: phpVersion * GitTagCrudController: tag * DockerImageTagCrudController: tag Non-matching ORDER BY parts are left untouched, so combined sorts (e.g. ORDER BY type ASC, frameworkVersion DESC) still work. --- CHANGELOG.md | 7 ++ src/Admin/SemverSort.php | 45 ++++++++++ .../Admin/DockerImageTagCrudController.php | 15 ++++ src/Controller/Admin/GitTagCrudController.php | 15 ++++ .../Admin/InstallationCrudController.php | 15 ++++ .../Admin/ModuleVersionCrudController.php | 15 ++++ .../Admin/PackageVersionCrudController.php | 15 ++++ src/Controller/Admin/SiteCrudController.php | 15 ++++ tests/Admin/SemverSortTest.php | 85 +++++++++++++++++++ 9 files changed, 227 insertions(+) create mode 100644 src/Admin/SemverSort.php create mode 100644 tests/Admin/SemverSortTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f60a413d..aad11a12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Sort the same admin version columns in semver order (not lexicographic) + when their headers are clicked, so `10.5.9` correctly orders above + `9.5.1`, and `11.2.10` above `11.2.8`. Implemented via a small + `SemverSort` helper that rewrites `ORDER BY entity.col` to + `ORDER BY SEMVER_NUMERIC(entity.col)` in each affected + `createIndexQueryBuilder()`. + - Add semver-aware filter on every admin version column (Installation `frameworkVersion` and `composerVersion`, PackageVersion `version` and `latest`, ModuleVersion `version`, Site `phpVersion`, GitTag `tag`, diff --git a/src/Admin/SemverSort.php b/src/Admin/SemverSort.php new file mode 100644 index 00000000..3276d331 --- /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 503d569f..4f0c1e0f 100644 --- a/src/Controller/Admin/DockerImageTagCrudController.php +++ b/src/Controller/Admin/DockerImageTagCrudController.php @@ -5,13 +5,19 @@ namespace App\Controller\Admin; use App\Admin\Field\VersionField; +use App\Admin\SemverSort; use App\Entity\DockerImageTag; use App\Form\Type\Admin\SemverFilter; +use Doctrine\ORM\QueryBuilder; +use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; +use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Config\Filters; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; +use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; +use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField; @@ -57,4 +63,13 @@ public function configureFilters(Filters $filters): Filters ->add(SemverFilter::new('tag')) ; } + + #[\Override] + public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder + { + $qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters); + SemverSort::apply($qb, 'tag'); + + return $qb; + } } diff --git a/src/Controller/Admin/GitTagCrudController.php b/src/Controller/Admin/GitTagCrudController.php index ee601fac..b9bee19a 100644 --- a/src/Controller/Admin/GitTagCrudController.php +++ b/src/Controller/Admin/GitTagCrudController.php @@ -5,13 +5,19 @@ namespace App\Controller\Admin; use App\Admin\Field\VersionField; +use App\Admin\SemverSort; use App\Entity\GitTag; use App\Form\Type\Admin\SemverFilter; +use Doctrine\ORM\QueryBuilder; +use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; +use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Config\Filters; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; +use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; +use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; class GitTagCrudController extends AbstractCrudController @@ -58,4 +64,13 @@ public function configureFilters(Filters $filters): Filters ->add(SemverFilter::new('tag')) ; } + + #[\Override] + public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder + { + $qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters); + SemverSort::apply($qb, 'tag'); + + return $qb; + } } diff --git a/src/Controller/Admin/InstallationCrudController.php b/src/Controller/Admin/InstallationCrudController.php index 415a0665..753186d5 100644 --- a/src/Controller/Admin/InstallationCrudController.php +++ b/src/Controller/Admin/InstallationCrudController.php @@ -9,15 +9,21 @@ use App\Admin\Field\RootDirField; use App\Admin\Field\ServerTypeField; use App\Admin\Field\VersionField; +use App\Admin\SemverSort; use App\Entity\Installation; use App\Form\Type\Admin\FrameworkFilter; use App\Form\Type\Admin\SemverFilter; use App\Trait\ExportCrudControllerTrait; +use Doctrine\ORM\QueryBuilder; +use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; +use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Config\Filters; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; +use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; +use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField; use EasyCorp\Bundle\EasyAdminBundle\Field\CodeEditorField; @@ -87,4 +93,13 @@ public function configureFilters(Filters $filters): Filters // ->add(SystemFilter::new('system')->mapped(false)) ; } + + #[\Override] + public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder + { + $qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters); + SemverSort::apply($qb, 'frameworkVersion', 'composerVersion'); + + return $qb; + } } diff --git a/src/Controller/Admin/ModuleVersionCrudController.php b/src/Controller/Admin/ModuleVersionCrudController.php index c7479f89..1a102b6e 100644 --- a/src/Controller/Admin/ModuleVersionCrudController.php +++ b/src/Controller/Admin/ModuleVersionCrudController.php @@ -5,13 +5,19 @@ namespace App\Controller\Admin; use App\Admin\Field\VersionField; +use App\Admin\SemverSort; use App\Entity\ModuleVersion; use App\Form\Type\Admin\SemverFilter; +use Doctrine\ORM\QueryBuilder; +use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; +use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Config\Filters; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; +use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; +use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; class ModuleVersionCrudController extends AbstractCrudController @@ -55,4 +61,13 @@ public function configureFilters(Filters $filters): Filters ->add(SemverFilter::new('version')) ; } + + #[\Override] + public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder + { + $qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters); + SemverSort::apply($qb, 'version'); + + return $qb; + } } diff --git a/src/Controller/Admin/PackageVersionCrudController.php b/src/Controller/Admin/PackageVersionCrudController.php index d5050172..913259e9 100644 --- a/src/Controller/Admin/PackageVersionCrudController.php +++ b/src/Controller/Admin/PackageVersionCrudController.php @@ -7,13 +7,19 @@ use App\Admin\Field\AdvisoryCountField; use App\Admin\Field\LatestStatusField; use App\Admin\Field\VersionField; +use App\Admin\SemverSort; use App\Entity\PackageVersion; use App\Form\Type\Admin\SemverFilter; +use Doctrine\ORM\QueryBuilder; +use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; +use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Config\Filters; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; +use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; +use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField; use EasyCorp\Bundle\EasyAdminBundle\Field\UrlField; @@ -69,4 +75,13 @@ public function configureFilters(Filters $filters): Filters ->add('latestStatus') ; } + + #[\Override] + public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder + { + $qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters); + SemverSort::apply($qb, 'version', 'latest'); + + return $qb; + } } diff --git a/src/Controller/Admin/SiteCrudController.php b/src/Controller/Admin/SiteCrudController.php index 535881d9..28898fbc 100644 --- a/src/Controller/Admin/SiteCrudController.php +++ b/src/Controller/Admin/SiteCrudController.php @@ -11,14 +11,20 @@ use App\Admin\Field\ServerTypeField; use App\Admin\Field\SiteTypeField; use App\Admin\Field\VersionField; +use App\Admin\SemverSort; use App\Entity\Site; use App\Form\Type\Admin\SemverFilter; use App\Trait\ExportCrudControllerTrait; +use Doctrine\ORM\QueryBuilder; +use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; +use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Config\Filters; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; +use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; +use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField; @@ -80,4 +86,13 @@ public function configureFilters(Filters $filters): Filters ->add(SemverFilter::new('phpVersion', 'PHP')) ->add('server'); } + + #[\Override] + public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder + { + $qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters); + SemverSort::apply($qb, 'phpVersion'); + + return $qb; + } } diff --git a/tests/Admin/SemverSortTest.php b/tests/Admin/SemverSortTest.php new file mode 100644 index 00000000..34a98014 --- /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(); + } +} From d7e38ea2c4f3f800724cd0f8a5e87cf085f4ac2e Mon Sep 17 00:00:00 2001 From: turegjorup Date: Mon, 18 May 2026 23:55:51 +0200 Subject: [PATCH 05/14] refactor: consolidate semver-sort wiring into SemverSortableCrudControllerTrait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six CRUD controllers all had the same createIndexQueryBuilder() override calling SemverSort::apply() with a property list — plus seven matching use statements each. Extracted the boilerplate into App\Trait\SemverSortableCrudControllerTrait, which declares one abstract semverSortedProperties(): array hook and provides the override. Each controller now reduces to: use SemverSortableCrudControllerTrait; protected function semverSortedProperties(): array { return ['frameworkVersion', 'composerVersion']; } Net −38 lines across the six controllers; behavior unchanged. --- .../Admin/DockerImageTagCrudController.php | 16 +++------ src/Controller/Admin/GitTagCrudController.php | 16 +++------ .../Admin/InstallationCrudController.php | 15 +++----- .../Admin/ModuleVersionCrudController.php | 16 +++------ .../Admin/PackageVersionCrudController.php | 16 +++------ src/Controller/Admin/SiteCrudController.php | 15 +++----- .../SemverSortableCrudControllerTrait.php | 36 +++++++++++++++++++ 7 files changed, 64 insertions(+), 66 deletions(-) create mode 100644 src/Trait/SemverSortableCrudControllerTrait.php diff --git a/src/Controller/Admin/DockerImageTagCrudController.php b/src/Controller/Admin/DockerImageTagCrudController.php index 4f0c1e0f..068318dc 100644 --- a/src/Controller/Admin/DockerImageTagCrudController.php +++ b/src/Controller/Admin/DockerImageTagCrudController.php @@ -5,24 +5,21 @@ namespace App\Controller\Admin; use App\Admin\Field\VersionField; -use App\Admin\SemverSort; use App\Entity\DockerImageTag; use App\Form\Type\Admin\SemverFilter; -use Doctrine\ORM\QueryBuilder; -use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; -use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; +use App\Trait\SemverSortableCrudControllerTrait; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Config\Filters; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; -use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; -use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField; class DockerImageTagCrudController extends AbstractCrudController { + use SemverSortableCrudControllerTrait; + public static function getEntityFqcn(): string { return DockerImageTag::class; @@ -65,11 +62,8 @@ public function configureFilters(Filters $filters): Filters } #[\Override] - public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder + protected function semverSortedProperties(): array { - $qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters); - SemverSort::apply($qb, 'tag'); - - return $qb; + return ['tag']; } } diff --git a/src/Controller/Admin/GitTagCrudController.php b/src/Controller/Admin/GitTagCrudController.php index b9bee19a..f715f1e2 100644 --- a/src/Controller/Admin/GitTagCrudController.php +++ b/src/Controller/Admin/GitTagCrudController.php @@ -5,23 +5,20 @@ namespace App\Controller\Admin; use App\Admin\Field\VersionField; -use App\Admin\SemverSort; use App\Entity\GitTag; use App\Form\Type\Admin\SemverFilter; -use Doctrine\ORM\QueryBuilder; -use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; -use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; +use App\Trait\SemverSortableCrudControllerTrait; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Config\Filters; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; -use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; -use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; class GitTagCrudController extends AbstractCrudController { + use SemverSortableCrudControllerTrait; + public static function getEntityFqcn(): string { return GitTag::class; @@ -66,11 +63,8 @@ public function configureFilters(Filters $filters): Filters } #[\Override] - public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder + protected function semverSortedProperties(): array { - $qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters); - SemverSort::apply($qb, 'tag'); - - return $qb; + return ['tag']; } } diff --git a/src/Controller/Admin/InstallationCrudController.php b/src/Controller/Admin/InstallationCrudController.php index 753186d5..beb5c0e5 100644 --- a/src/Controller/Admin/InstallationCrudController.php +++ b/src/Controller/Admin/InstallationCrudController.php @@ -9,21 +9,16 @@ use App\Admin\Field\RootDirField; use App\Admin\Field\ServerTypeField; use App\Admin\Field\VersionField; -use App\Admin\SemverSort; use App\Entity\Installation; use App\Form\Type\Admin\FrameworkFilter; use App\Form\Type\Admin\SemverFilter; use App\Trait\ExportCrudControllerTrait; -use Doctrine\ORM\QueryBuilder; -use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; -use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; +use App\Trait\SemverSortableCrudControllerTrait; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Config\Filters; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; -use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; -use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField; use EasyCorp\Bundle\EasyAdminBundle\Field\CodeEditorField; @@ -32,6 +27,7 @@ class InstallationCrudController extends AbstractCrudController { use ExportCrudControllerTrait; + use SemverSortableCrudControllerTrait; public static function getEntityFqcn(): string { @@ -95,11 +91,8 @@ public function configureFilters(Filters $filters): Filters } #[\Override] - public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder + protected function semverSortedProperties(): array { - $qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters); - SemverSort::apply($qb, 'frameworkVersion', 'composerVersion'); - - return $qb; + return ['frameworkVersion', 'composerVersion']; } } diff --git a/src/Controller/Admin/ModuleVersionCrudController.php b/src/Controller/Admin/ModuleVersionCrudController.php index 1a102b6e..7db17aa2 100644 --- a/src/Controller/Admin/ModuleVersionCrudController.php +++ b/src/Controller/Admin/ModuleVersionCrudController.php @@ -5,23 +5,20 @@ namespace App\Controller\Admin; use App\Admin\Field\VersionField; -use App\Admin\SemverSort; use App\Entity\ModuleVersion; use App\Form\Type\Admin\SemverFilter; -use Doctrine\ORM\QueryBuilder; -use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; -use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; +use App\Trait\SemverSortableCrudControllerTrait; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Config\Filters; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; -use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; -use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; class ModuleVersionCrudController extends AbstractCrudController { + use SemverSortableCrudControllerTrait; + public static function getEntityFqcn(): string { return ModuleVersion::class; @@ -63,11 +60,8 @@ public function configureFilters(Filters $filters): Filters } #[\Override] - public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder + protected function semverSortedProperties(): array { - $qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters); - SemverSort::apply($qb, 'version'); - - return $qb; + return ['version']; } } diff --git a/src/Controller/Admin/PackageVersionCrudController.php b/src/Controller/Admin/PackageVersionCrudController.php index 913259e9..68f521f7 100644 --- a/src/Controller/Admin/PackageVersionCrudController.php +++ b/src/Controller/Admin/PackageVersionCrudController.php @@ -7,25 +7,22 @@ use App\Admin\Field\AdvisoryCountField; use App\Admin\Field\LatestStatusField; use App\Admin\Field\VersionField; -use App\Admin\SemverSort; use App\Entity\PackageVersion; use App\Form\Type\Admin\SemverFilter; -use Doctrine\ORM\QueryBuilder; -use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; -use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; +use App\Trait\SemverSortableCrudControllerTrait; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Config\Filters; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; -use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; -use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField; use EasyCorp\Bundle\EasyAdminBundle\Field\UrlField; class PackageVersionCrudController extends AbstractCrudController { + use SemverSortableCrudControllerTrait; + public static function getEntityFqcn(): string { return PackageVersion::class; @@ -77,11 +74,8 @@ public function configureFilters(Filters $filters): Filters } #[\Override] - public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder + protected function semverSortedProperties(): array { - $qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters); - SemverSort::apply($qb, 'version', 'latest'); - - return $qb; + return ['version', 'latest']; } } diff --git a/src/Controller/Admin/SiteCrudController.php b/src/Controller/Admin/SiteCrudController.php index 28898fbc..aabaa033 100644 --- a/src/Controller/Admin/SiteCrudController.php +++ b/src/Controller/Admin/SiteCrudController.php @@ -11,26 +11,22 @@ use App\Admin\Field\ServerTypeField; use App\Admin\Field\SiteTypeField; use App\Admin\Field\VersionField; -use App\Admin\SemverSort; use App\Entity\Site; use App\Form\Type\Admin\SemverFilter; use App\Trait\ExportCrudControllerTrait; -use Doctrine\ORM\QueryBuilder; -use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; -use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; +use App\Trait\SemverSortableCrudControllerTrait; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Config\Filters; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; -use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; -use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField; class SiteCrudController extends AbstractCrudController { use ExportCrudControllerTrait; + use SemverSortableCrudControllerTrait; public function __construct() { @@ -88,11 +84,8 @@ public function configureFilters(Filters $filters): Filters } #[\Override] - public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder + protected function semverSortedProperties(): array { - $qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters); - SemverSort::apply($qb, 'phpVersion'); - - return $qb; + return ['phpVersion']; } } diff --git a/src/Trait/SemverSortableCrudControllerTrait.php b/src/Trait/SemverSortableCrudControllerTrait.php new file mode 100644 index 00000000..87b4b177 --- /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; + } +} From 93adf362f69570b2a4a986310ed1ddd35f2ce0f5 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 19 May 2026 00:08:20 +0200 Subject: [PATCH 06/14] Update Changelog --- CHANGELOG.md | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aad11a12..c6b041b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,22 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Sort the same admin version columns in semver order (not lexicographic) - when their headers are clicked, so `10.5.9` correctly orders above - `9.5.1`, and `11.2.10` above `11.2.8`. Implemented via a small - `SemverSort` helper that rewrites `ORDER BY entity.col` to - `ORDER BY SEMVER_NUMERIC(entity.col)` in each affected - `createIndexQueryBuilder()`. - -- Add semver-aware filter on every admin version column (Installation - `frameworkVersion` and `composerVersion`, PackageVersion `version` and - `latest`, ModuleVersion `version`, Site `phpVersion`, GitTag `tag`, - DockerImageTag `tag`) so the admin can filter with `=, !=, >, >=, <, <=` - or by an inclusive/exclusive range (`between (inclusive)` / `between - (exclusive)`) using semver order instead of lexicographic string - comparison. Values with a leading `v`/`V` (e.g. `v5.5.40`) are accepted; - non-semver values - are excluded from results when the filter is active. +- [#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 From d9be81eae7c737d5a5ee27d7cf107076738485b7 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 19 May 2026 00:35:51 +0200 Subject: [PATCH 07/14] fix: SemverFilter must respect value2 with directional operators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filling the upper-bound field together with >=, >, <=, or < used to silently drop the upper bound, so ?comparison=>=&value=11.0.0&value2=11.2.0 returned everything >= 11.0.0 instead of [11.0.0, 11.2.0]. apply() now auto-promotes to a range whenever value2 is supplied with a directional operator. The operator carries inclusivity (>=/<= → inclusive, >/< → exclusive) and the two values are sorted numerically so the order the user typed them in doesn't matter. = and != still treat the second value as ignored (exact match semantics). Form placeholder updated to "upper bound (optional, makes it a range)" so it no longer reads as exclusive to between. --- src/Form/Type/Admin/SemverFilter.php | 40 ++++++++++++---- src/Form/Type/Admin/SemverFilterType.php | 2 +- tests/Form/Type/Admin/SemverFilterTest.php | 54 ++++++++++++++++++++++ 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/src/Form/Type/Admin/SemverFilter.php b/src/Form/Type/Admin/SemverFilter.php index b8f092df..2ffe1205 100644 --- a/src/Form/Type/Admin/SemverFilter.php +++ b/src/Form/Type/Admin/SemverFilter.php @@ -39,9 +39,23 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, return; } - $isRange = SemverFilterType::COMPARISON_BETWEEN === $comparison - || SemverFilterType::COMPARISON_BETWEEN_EXCLUSIVE === $comparison; - if ($isRange && '' === $value2) { + // 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; } @@ -70,9 +84,19 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, // references its argument five times) and PDO would complain about // bound-variable count. if ($isRange) { - [$lowerOp, $upperOp] = SemverFilterType::COMPARISON_BETWEEN === $comparison - ? ['>=', '<='] - : ['>', '<']; + $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( @@ -83,8 +107,8 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, $parameter, $upperOp, )) - ->setParameter($parameter.'_min', self::toSemverNumeric($value)) - ->setParameter($parameter.'_max', self::toSemverNumeric($value2)) + ->setParameter($parameter.'_min', $min) + ->setParameter($parameter.'_max', $max) ; return; diff --git a/src/Form/Type/Admin/SemverFilterType.php b/src/Form/Type/Admin/SemverFilterType.php index 939df161..4f6006c7 100644 --- a/src/Form/Type/Admin/SemverFilterType.php +++ b/src/Form/Type/Admin/SemverFilterType.php @@ -46,7 +46,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]) ->add('value2', TextType::class, [ 'required' => false, - 'attr' => ['placeholder' => 'upper bound (when between)'], + 'attr' => ['placeholder' => 'upper bound (optional, makes it a range)'], ]) ; } diff --git a/tests/Form/Type/Admin/SemverFilterTest.php b/tests/Form/Type/Admin/SemverFilterTest.php index e95c721c..57b1c809 100644 --- a/tests/Form/Type/Admin/SemverFilterTest.php +++ b/tests/Form/Type/Admin/SemverFilterTest.php @@ -102,6 +102,60 @@ public function testApplyShortCircuitsRangeOnInvalidUpperBound(): void 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 */ From 1fe33d54ee81e9f9a10fe9682bdca67f3ff8229e Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 19 May 2026 00:36:44 +0200 Subject: [PATCH 08/14] docs: add changelog entry for #77 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6b041b8..317c56d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- [#77](https://github.com/itk-dev/devops_itksites/pull/77) + Fix SemverFilter: respect value2 with directional operators + - [#75](https://github.com/itk-dev/devops_itksites/pull/75) Add semver-aware filter on every admin version column, make version column semver sortable From 0b6b227d3b749e794820d30e3545e6c28569965b Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 19 May 2026 00:25:02 +0200 Subject: [PATCH 09/14] feat: add server.type filter and sort to Installation, Site, Domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Server type" dropdown filter (Prod / Stg / DevOps / GPU via ServerTypeType::CHOICES) and makes the Type column header clickable to sort on those three admin lists. ServerTypeFilter learned to handle dotted property names by joining the relation itself — EasyAdmin doesn't auto-join for filters on nested paths (https://github.com/EasyCorp/EasyAdminBundle/issues/4120). The filter reuses the plain relation name as the join alias to match EA's own sort-side auto-join, so combining filter + sort on the same association produces a single JOIN in the generated SQL. --- src/Controller/Admin/DomainCrudController.php | 4 ++- .../Admin/InstallationCrudController.php | 4 ++- src/Controller/Admin/SiteCrudController.php | 6 ++-- src/Form/Type/Admin/ServerTypeFilter.php | 29 +++++++++++++++++-- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/Controller/Admin/DomainCrudController.php b/src/Controller/Admin/DomainCrudController.php index 060e8efc..d1a98abe 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/InstallationCrudController.php b/src/Controller/Admin/InstallationCrudController.php index beb5c0e5..cad1a95c 100644 --- a/src/Controller/Admin/InstallationCrudController.php +++ b/src/Controller/Admin/InstallationCrudController.php @@ -12,6 +12,7 @@ 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; @@ -68,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(); @@ -86,6 +87,7 @@ public function configureFilters(Filters $filters): Filters ->add(SemverFilter::new('composerVersion', 'Comp.')) ->add('rootDir') ->add('server') + ->add(ServerTypeFilter::new('server.type', 'Server type')) // ->add(SystemFilter::new('system')->mapped(false)) ; } diff --git a/src/Controller/Admin/SiteCrudController.php b/src/Controller/Admin/SiteCrudController.php index aabaa033..4d49bb4b 100644 --- a/src/Controller/Admin/SiteCrudController.php +++ b/src/Controller/Admin/SiteCrudController.php @@ -13,6 +13,7 @@ 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; @@ -67,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(); @@ -80,7 +81,8 @@ public function configureFilters(Filters $filters): Filters ->add('primaryDomain') ->add('configFilePath') ->add(SemverFilter::new('phpVersion', 'PHP')) - ->add('server'); + ->add('server') + ->add(ServerTypeFilter::new('server.type', 'Server type')); } #[\Override] diff --git a/src/Form/Type/Admin/ServerTypeFilter.php b/src/Form/Type/Admin/ServerTypeFilter.php index 502dd917..9c78a0db 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); } } From c303c0d2fcc4b6cec9197411f0cce102cd4773a9 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 19 May 2026 00:28:22 +0200 Subject: [PATCH 10/14] docs: add changelog entry for #76 --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 317c56d8..b174142e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#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 From 33836c3b9ac686a646bf2450f69fad9846db97dc Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 19 May 2026 00:45:27 +0200 Subject: [PATCH 11/14] test: cover ServerTypeFilter nested-property branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codecov flagged 0% patch coverage on the new dotted-path handling. Three KernelTest cases cover: * flat property → no JOIN, compares on root alias * nested property → auto-LEFT-JOINs the relation under the plain alias * nested property when the alias already exists → no duplicate JOIN --- .../Form/Type/Admin/ServerTypeFilterTest.php | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/Form/Type/Admin/ServerTypeFilterTest.php diff --git a/tests/Form/Type/Admin/ServerTypeFilterTest.php b/tests/Form/Type/Admin/ServerTypeFilterTest.php new file mode 100644 index 00000000..8efde63f --- /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(); + } +} From 72d4d2422e98415bc6d4a51766855f8ee89bd3d4 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 19 May 2026 00:52:16 +0200 Subject: [PATCH 12/14] chore: update composer dependencies, fix php-cs-fixer deprecation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * composer update — minor/patch bumps across Symfony 8.0.9→8.0.11, EasyAdmin 5.0.7→5.0.8, php-cs-fixer 3.95.1→3.95.2, PHPUnit 13.1.8→13.1.10, PHPStan, Doctrine and friends. No security advisories. composer.json validates strict. * Replace the deprecated PHP_CS_FIXER_IGNORE_ENV=1 env var on the coding-standards scripts with $config->setUnsupportedPhpVersionAllowed(true) in .php-cs-fixer.dist.php (the supported replacement; the env var is removed in php-cs-fixer 4.0). --- .php-cs-fixer.dist.php | 4 + composer.json | 4 +- composer.lock | 336 ++++++++++++++++++++--------------------- 3 files changed, 174 insertions(+), 170 deletions(-) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 3bc9f35b..9acdc60b 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/composer.json b/composer.json index 4684cf3e..17376580 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 78480cef..9392a9de 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", From e276696ee0e8676f8a58e0ee2185d7b8612efe9e Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 19 May 2026 00:52:52 +0200 Subject: [PATCH 13/14] docs: add changelog entry for #78 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b174142e..328f16a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- [#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) From 63b61fac264f84c0aad451d20ca0f91b3492563f Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 19 May 2026 00:57:49 +0200 Subject: [PATCH 14/14] release: 1.11.0 --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 328f16a6..7ee3096f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ 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) @@ -174,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