Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- [#75](https://github.com/itk-dev/devops_itksites/pull/75)
Add semver-aware filter on every admin version column, make version
column semver sortable

## [1.10.1] - 2026-05-11

- [#71](https://github.com/itk-dev/devops_itksites/pull/71)
Expand Down
3 changes: 3 additions & 0 deletions config/packages/doctrine.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 45 additions & 0 deletions src/Admin/SemverSort.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace App\Admin;

use Doctrine\ORM\QueryBuilder;

final class SemverSort
{
/**
* Rewrite the QueryBuilder's ORDER BY so that the listed root-alias
* properties sort in semver order (via the SEMVER_NUMERIC DQL function)
* instead of lexicographic string order. Other ORDER BY parts are
* left untouched. Intended for use from a CRUD controller's
* createIndexQueryBuilder() after the parent has built the QB.
*/
public static function apply(QueryBuilder $qb, string ...$properties): void
{
$orderByParts = $qb->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);
}
}
}
}
12 changes: 11 additions & 1 deletion src/Controller/Admin/DockerImageTagCrudController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use App\Admin\Field\VersionField;
use App\Entity\DockerImageTag;
use App\Form\Type\Admin\SemverFilter;
use App\Trait\SemverSortableCrudControllerTrait;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
Expand All @@ -16,6 +18,8 @@

class DockerImageTagCrudController extends AbstractCrudController
{
use SemverSortableCrudControllerTrait;

public static function getEntityFqcn(): string
{
return DockerImageTag::class;
Expand Down Expand Up @@ -53,7 +57,13 @@ public function configureFilters(Filters $filters): Filters
{
return $filters
->add('dockerImage')
->add('tag')
->add(SemverFilter::new('tag'))
;
}

#[\Override]
protected function semverSortedProperties(): array
{
return ['tag'];
}
}
12 changes: 11 additions & 1 deletion src/Controller/Admin/GitTagCrudController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use App\Admin\Field\VersionField;
use App\Entity\GitTag;
use App\Form\Type\Admin\SemverFilter;
use App\Trait\SemverSortableCrudControllerTrait;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
Expand All @@ -15,6 +17,8 @@

class GitTagCrudController extends AbstractCrudController
{
use SemverSortableCrudControllerTrait;

public static function getEntityFqcn(): string
{
return GitTag::class;
Expand Down Expand Up @@ -54,7 +58,13 @@ public function configureFilters(Filters $filters): Filters
{
return $filters
->add('repo')
->add('tag')
->add(SemverFilter::new('tag'))
;
}

#[\Override]
protected function semverSortedProperties(): array
{
return ['tag'];
}
}
13 changes: 11 additions & 2 deletions src/Controller/Admin/InstallationCrudController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
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 App\Trait\SemverSortableCrudControllerTrait;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
Expand All @@ -25,6 +27,7 @@
class InstallationCrudController extends AbstractCrudController
{
use ExportCrudControllerTrait;
use SemverSortableCrudControllerTrait;

public static function getEntityFqcn(): string
{
Expand Down Expand Up @@ -77,13 +80,19 @@ 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))
;
}

#[\Override]
protected function semverSortedProperties(): array
{
return ['frameworkVersion', 'composerVersion'];
}
}
12 changes: 11 additions & 1 deletion src/Controller/Admin/ModuleVersionCrudController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use App\Admin\Field\VersionField;
use App\Entity\ModuleVersion;
use App\Form\Type\Admin\SemverFilter;
use App\Trait\SemverSortableCrudControllerTrait;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
Expand All @@ -15,6 +17,8 @@

class ModuleVersionCrudController extends AbstractCrudController
{
use SemverSortableCrudControllerTrait;

public static function getEntityFqcn(): string
{
return ModuleVersion::class;
Expand Down Expand Up @@ -51,7 +55,13 @@ public function configureFilters(Filters $filters): Filters
{
return $filters
->add('module')
->add('version')
->add(SemverFilter::new('version'))
;
}

#[\Override]
protected function semverSortedProperties(): array
{
return ['version'];
}
}
14 changes: 12 additions & 2 deletions src/Controller/Admin/PackageVersionCrudController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use App\Admin\Field\LatestStatusField;
use App\Admin\Field\VersionField;
use App\Entity\PackageVersion;
use App\Form\Type\Admin\SemverFilter;
use App\Trait\SemverSortableCrudControllerTrait;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
Expand All @@ -19,6 +21,8 @@

class PackageVersionCrudController extends AbstractCrudController
{
use SemverSortableCrudControllerTrait;

public static function getEntityFqcn(): string
{
return PackageVersion::class;
Expand Down Expand Up @@ -63,9 +67,15 @@ public function configureFilters(Filters $filters): Filters
{
return $filters
->add('package')
->add('version')
->add('latest')
->add(SemverFilter::new('version'))
->add(SemverFilter::new('latest'))
->add('latestStatus')
;
}

#[\Override]
protected function semverSortedProperties(): array
{
return ['version', 'latest'];
}
}
11 changes: 10 additions & 1 deletion src/Controller/Admin/SiteCrudController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
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 App\Trait\SemverSortableCrudControllerTrait;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
Expand All @@ -24,6 +26,7 @@
class SiteCrudController extends AbstractCrudController
{
use ExportCrudControllerTrait;
use SemverSortableCrudControllerTrait;

public function __construct()
{
Expand Down Expand Up @@ -76,7 +79,13 @@ public function configureFilters(Filters $filters): Filters
return $filters
->add('primaryDomain')
->add('configFilePath')
->add('phpVersion')
->add(SemverFilter::new('phpVersion', 'PHP'))
->add('server');
}

#[\Override]
protected function semverSortedProperties(): array
{
return ['phpVersion'];
}
}
71 changes: 71 additions & 0 deletions src/Doctrine/Functions/SemverNumeric.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

declare(strict_types=1);

namespace App\Doctrine\Functions;

use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\TokenType;

/**
* 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^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.
*
* 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
{
private Node $versionExpression;

#[\Override]
public function parse(Parser $parser): void
{
$parser->match(TokenType::T_IDENTIFIER);
$parser->match(TokenType::T_OPEN_PARENTHESIS);
$this->versionExpression = $parser->StringPrimary();
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
}

#[\Override]
public function getSql(SqlWalker $sqlWalker): string
{
$expr = $this->versionExpression->dispatch($sqlWalker);
// Strip an optional leading "v" or "V" so "v5.5.40" parses identically to "5.5.40".
$stripped = sprintf("TRIM(LEADING 'v' FROM TRIM(LEADING 'V' FROM %s))", $expr);
$padded = sprintf("CONCAT(%s, '.0.0.0')", $stripped);
$segment = static fn (int $n): string => sprintf(
"CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(%s, '.', %d), '.', -1) AS UNSIGNED)",
$padded,
$n,
);

// Return NULL for anything that isn't a dotted-number sequence so non-semver
// rows ("?", "unknown", "main", "release-1.0", ...) get NULL comparisons
// and are excluded from the result set regardless of operator.
return sprintf(
"(CASE WHEN %s REGEXP '^[vV]?[0-9]+(\\\\.[0-9]+){0,3}\$' "
.'THEN (%s * 1000000000000 + %s * 100000000 + %s * 10000 + %s) '
.'ELSE NULL END)',
$expr,
$segment(1),
$segment(2),
$segment(3),
$segment(4),
);
}
}
Loading
Loading