Skip to content

Commit 25ab300

Browse files
authored
Merge pull request #75 from itk-dev/feature/installation-semver-filter
feat: add semver filter to admin version columns
2 parents 11b3f7c + 93adf36 commit 25ab300

15 files changed

Lines changed: 638 additions & 8 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
- [#75](https://github.com/itk-dev/devops_itksites/pull/75)
11+
Add semver-aware filter on every admin version column, make version
12+
column semver sortable
13+
1014
## [1.10.1] - 2026-05-11
1115

1216
- [#71](https://github.com/itk-dev/devops_itksites/pull/71)

config/packages/doctrine.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ doctrine:
2121
dir: '%kernel.project_dir%/src/Entity'
2222
prefix: 'App\Entity'
2323
alias: App
24+
dql:
25+
numeric_functions:
26+
SEMVER_NUMERIC: App\Doctrine\Functions\SemverNumeric
2427
controller_resolver:
2528
auto_mapping: false
2629

src/Admin/SemverSort.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Admin;
6+
7+
use Doctrine\ORM\QueryBuilder;
8+
9+
final class SemverSort
10+
{
11+
/**
12+
* Rewrite the QueryBuilder's ORDER BY so that the listed root-alias
13+
* properties sort in semver order (via the SEMVER_NUMERIC DQL function)
14+
* instead of lexicographic string order. Other ORDER BY parts are
15+
* left untouched. Intended for use from a CRUD controller's
16+
* createIndexQueryBuilder() after the parent has built the QB.
17+
*/
18+
public static function apply(QueryBuilder $qb, string ...$properties): void
19+
{
20+
$orderByParts = $qb->getDQLPart('orderBy');
21+
if ([] === $orderByParts) {
22+
return;
23+
}
24+
25+
$alias = (string) current($qb->getRootAliases());
26+
$needles = [];
27+
foreach ($properties as $property) {
28+
$needles[$alias.'.'.$property] = 'SEMVER_NUMERIC('.$alias.'.'.$property.')';
29+
}
30+
31+
$qb->resetDQLPart('orderBy');
32+
foreach ($orderByParts as $orderBy) {
33+
foreach ($orderBy->getParts() as $part) {
34+
if (preg_match('/^(.+?)\s+(ASC|DESC)$/i', $part, $m)) {
35+
[$expr, $dir] = [$m[1], strtoupper($m[2])];
36+
} else {
37+
[$expr, $dir] = [$part, 'ASC'];
38+
}
39+
40+
$expr = $needles[$expr] ?? $expr;
41+
$qb->addOrderBy($expr, $dir);
42+
}
43+
}
44+
}
45+
}

src/Controller/Admin/DockerImageTagCrudController.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
use App\Admin\Field\VersionField;
88
use App\Entity\DockerImageTag;
9+
use App\Form\Type\Admin\SemverFilter;
10+
use App\Trait\SemverSortableCrudControllerTrait;
911
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
1012
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
1113
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
@@ -16,6 +18,8 @@
1618

1719
class DockerImageTagCrudController extends AbstractCrudController
1820
{
21+
use SemverSortableCrudControllerTrait;
22+
1923
public static function getEntityFqcn(): string
2024
{
2125
return DockerImageTag::class;
@@ -53,7 +57,13 @@ public function configureFilters(Filters $filters): Filters
5357
{
5458
return $filters
5559
->add('dockerImage')
56-
->add('tag')
60+
->add(SemverFilter::new('tag'))
5761
;
5862
}
63+
64+
#[\Override]
65+
protected function semverSortedProperties(): array
66+
{
67+
return ['tag'];
68+
}
5969
}

src/Controller/Admin/GitTagCrudController.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
use App\Admin\Field\VersionField;
88
use App\Entity\GitTag;
9+
use App\Form\Type\Admin\SemverFilter;
10+
use App\Trait\SemverSortableCrudControllerTrait;
911
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
1012
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
1113
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
@@ -15,6 +17,8 @@
1517

1618
class GitTagCrudController extends AbstractCrudController
1719
{
20+
use SemverSortableCrudControllerTrait;
21+
1822
public static function getEntityFqcn(): string
1923
{
2024
return GitTag::class;
@@ -54,7 +58,13 @@ public function configureFilters(Filters $filters): Filters
5458
{
5559
return $filters
5660
->add('repo')
57-
->add('tag')
61+
->add(SemverFilter::new('tag'))
5862
;
5963
}
64+
65+
#[\Override]
66+
protected function semverSortedProperties(): array
67+
{
68+
return ['tag'];
69+
}
6070
}

src/Controller/Admin/InstallationCrudController.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
use App\Admin\Field\VersionField;
1212
use App\Entity\Installation;
1313
use App\Form\Type\Admin\FrameworkFilter;
14+
use App\Form\Type\Admin\SemverFilter;
1415
use App\Trait\ExportCrudControllerTrait;
16+
use App\Trait\SemverSortableCrudControllerTrait;
1517
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
1618
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
1719
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
@@ -25,6 +27,7 @@
2527
class InstallationCrudController extends AbstractCrudController
2628
{
2729
use ExportCrudControllerTrait;
30+
use SemverSortableCrudControllerTrait;
2831

2932
public static function getEntityFqcn(): string
3033
{
@@ -77,13 +80,19 @@ public function configureFilters(Filters $filters): Filters
7780
{
7881
return $filters
7982
->add(FrameworkFilter::new('type'))
80-
->add('frameworkVersion')
83+
->add(SemverFilter::new('frameworkVersion', 'ver.'))
8184
->add('lts')
8285
->add('eol')
83-
->add('composerVersion')
86+
->add(SemverFilter::new('composerVersion', 'Comp.'))
8487
->add('rootDir')
8588
->add('server')
8689
// ->add(SystemFilter::new('system')->mapped(false))
8790
;
8891
}
92+
93+
#[\Override]
94+
protected function semverSortedProperties(): array
95+
{
96+
return ['frameworkVersion', 'composerVersion'];
97+
}
8998
}

src/Controller/Admin/ModuleVersionCrudController.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
use App\Admin\Field\VersionField;
88
use App\Entity\ModuleVersion;
9+
use App\Form\Type\Admin\SemverFilter;
10+
use App\Trait\SemverSortableCrudControllerTrait;
911
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
1012
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
1113
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
@@ -15,6 +17,8 @@
1517

1618
class ModuleVersionCrudController extends AbstractCrudController
1719
{
20+
use SemverSortableCrudControllerTrait;
21+
1822
public static function getEntityFqcn(): string
1923
{
2024
return ModuleVersion::class;
@@ -51,7 +55,13 @@ public function configureFilters(Filters $filters): Filters
5155
{
5256
return $filters
5357
->add('module')
54-
->add('version')
58+
->add(SemverFilter::new('version'))
5559
;
5660
}
61+
62+
#[\Override]
63+
protected function semverSortedProperties(): array
64+
{
65+
return ['version'];
66+
}
5767
}

src/Controller/Admin/PackageVersionCrudController.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
use App\Admin\Field\LatestStatusField;
99
use App\Admin\Field\VersionField;
1010
use App\Entity\PackageVersion;
11+
use App\Form\Type\Admin\SemverFilter;
12+
use App\Trait\SemverSortableCrudControllerTrait;
1113
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
1214
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
1315
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
@@ -19,6 +21,8 @@
1921

2022
class PackageVersionCrudController extends AbstractCrudController
2123
{
24+
use SemverSortableCrudControllerTrait;
25+
2226
public static function getEntityFqcn(): string
2327
{
2428
return PackageVersion::class;
@@ -63,9 +67,15 @@ public function configureFilters(Filters $filters): Filters
6367
{
6468
return $filters
6569
->add('package')
66-
->add('version')
67-
->add('latest')
70+
->add(SemverFilter::new('version'))
71+
->add(SemverFilter::new('latest'))
6872
->add('latestStatus')
6973
;
7074
}
75+
76+
#[\Override]
77+
protected function semverSortedProperties(): array
78+
{
79+
return ['version', 'latest'];
80+
}
7181
}

src/Controller/Admin/SiteCrudController.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
use App\Admin\Field\SiteTypeField;
1313
use App\Admin\Field\VersionField;
1414
use App\Entity\Site;
15+
use App\Form\Type\Admin\SemverFilter;
1516
use App\Trait\ExportCrudControllerTrait;
17+
use App\Trait\SemverSortableCrudControllerTrait;
1618
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
1719
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
1820
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
@@ -24,6 +26,7 @@
2426
class SiteCrudController extends AbstractCrudController
2527
{
2628
use ExportCrudControllerTrait;
29+
use SemverSortableCrudControllerTrait;
2730

2831
public function __construct()
2932
{
@@ -76,7 +79,13 @@ public function configureFilters(Filters $filters): Filters
7679
return $filters
7780
->add('primaryDomain')
7881
->add('configFilePath')
79-
->add('phpVersion')
82+
->add(SemverFilter::new('phpVersion', 'PHP'))
8083
->add('server');
8184
}
85+
86+
#[\Override]
87+
protected function semverSortedProperties(): array
88+
{
89+
return ['phpVersion'];
90+
}
8291
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Doctrine\Functions;
6+
7+
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
8+
use Doctrine\ORM\Query\AST\Node;
9+
use Doctrine\ORM\Query\Parser;
10+
use Doctrine\ORM\Query\SqlWalker;
11+
use Doctrine\ORM\Query\TokenType;
12+
13+
/**
14+
* SEMVER_NUMERIC(versionString) — MariaDB DQL function returning a sortable
15+
* BIGINT for a dotted-version string. Padding the string with ".0.0.0" lets
16+
* us treat shorter versions ("10", "10.5") as if all four segments were
17+
* present. Each segment is given 10^4 of room (max 9999 per segment),
18+
* packed left-to-right, so "10.5.9" becomes
19+
* 10·10^12 + 5·10^8 + 9·10^4 = 10_000_500_090_000.
20+
*
21+
* The 10^4 cap keeps the maximum value (~10^16) well inside PHP's
22+
* 64-bit PHP_INT_MAX (~9.22·10^18), so callers can mirror this arithmetic
23+
* in PHP to bind a single BIGINT parameter rather than feeding the
24+
* raw string back through the DQL function (which would expand the
25+
* argument-placeholder several times — see SemverFilter::toSemverNumeric()).
26+
*
27+
* Non-semver inputs (anything that doesn't match the dotted-number shape)
28+
* collapse to NULL via the outer CASE so they're excluded from any
29+
* comparison.
30+
*/
31+
class SemverNumeric extends FunctionNode
32+
{
33+
private Node $versionExpression;
34+
35+
#[\Override]
36+
public function parse(Parser $parser): void
37+
{
38+
$parser->match(TokenType::T_IDENTIFIER);
39+
$parser->match(TokenType::T_OPEN_PARENTHESIS);
40+
$this->versionExpression = $parser->StringPrimary();
41+
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
42+
}
43+
44+
#[\Override]
45+
public function getSql(SqlWalker $sqlWalker): string
46+
{
47+
$expr = $this->versionExpression->dispatch($sqlWalker);
48+
// Strip an optional leading "v" or "V" so "v5.5.40" parses identically to "5.5.40".
49+
$stripped = sprintf("TRIM(LEADING 'v' FROM TRIM(LEADING 'V' FROM %s))", $expr);
50+
$padded = sprintf("CONCAT(%s, '.0.0.0')", $stripped);
51+
$segment = static fn (int $n): string => sprintf(
52+
"CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(%s, '.', %d), '.', -1) AS UNSIGNED)",
53+
$padded,
54+
$n,
55+
);
56+
57+
// Return NULL for anything that isn't a dotted-number sequence so non-semver
58+
// rows ("?", "unknown", "main", "release-1.0", ...) get NULL comparisons
59+
// and are excluded from the result set regardless of operator.
60+
return sprintf(
61+
"(CASE WHEN %s REGEXP '^[vV]?[0-9]+(\\\\.[0-9]+){0,3}\$' "
62+
.'THEN (%s * 1000000000000 + %s * 100000000 + %s * 10000 + %s) '
63+
.'ELSE NULL END)',
64+
$expr,
65+
$segment(1),
66+
$segment(2),
67+
$segment(3),
68+
$segment(4),
69+
);
70+
}
71+
}

0 commit comments

Comments
 (0)