From 9bcda1af82c96fbfeb6a27905e2625428e01408c Mon Sep 17 00:00:00 2001 From: turegjorup Date: Mon, 18 May 2026 23:11:17 +0200 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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