Skip to content

Commit 80aa6f2

Browse files
committed
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.<prop> <dir>" into "SEMVER_NUMERIC(entity.<prop>) <dir>" 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.
1 parent a2f371c commit 80aa6f2

9 files changed

Lines changed: 227 additions & 0 deletions

CHANGELOG.md

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

88
## [Unreleased]
99

10+
- Sort the same admin version columns in semver order (not lexicographic)
11+
when their headers are clicked, so `10.5.9` correctly orders above
12+
`9.5.1`, and `11.2.10` above `11.2.8`. Implemented via a small
13+
`SemverSort` helper that rewrites `ORDER BY entity.col` to
14+
`ORDER BY SEMVER_NUMERIC(entity.col)` in each affected
15+
`createIndexQueryBuilder()`.
16+
1017
- Add semver-aware filter on every admin version column (Installation
1118
`frameworkVersion` and `composerVersion`, PackageVersion `version` and
1219
`latest`, ModuleVersion `version`, Site `phpVersion`, GitTag `tag`,

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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,19 @@
55
namespace App\Controller\Admin;
66

77
use App\Admin\Field\VersionField;
8+
use App\Admin\SemverSort;
89
use App\Entity\DockerImageTag;
910
use App\Form\Type\Admin\SemverFilter;
11+
use Doctrine\ORM\QueryBuilder;
12+
use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection;
13+
use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection;
1014
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
1115
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
1216
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
1317
use EasyCorp\Bundle\EasyAdminBundle\Config\Filters;
1418
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
19+
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
20+
use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto;
1521
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
1622
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
1723

@@ -57,4 +63,13 @@ public function configureFilters(Filters $filters): Filters
5763
->add(SemverFilter::new('tag'))
5864
;
5965
}
66+
67+
#[\Override]
68+
public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder
69+
{
70+
$qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters);
71+
SemverSort::apply($qb, 'tag');
72+
73+
return $qb;
74+
}
6075
}

src/Controller/Admin/GitTagCrudController.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,19 @@
55
namespace App\Controller\Admin;
66

77
use App\Admin\Field\VersionField;
8+
use App\Admin\SemverSort;
89
use App\Entity\GitTag;
910
use App\Form\Type\Admin\SemverFilter;
11+
use Doctrine\ORM\QueryBuilder;
12+
use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection;
13+
use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection;
1014
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
1115
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
1216
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
1317
use EasyCorp\Bundle\EasyAdminBundle\Config\Filters;
1418
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
19+
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
20+
use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto;
1521
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
1622

1723
class GitTagCrudController extends AbstractCrudController
@@ -58,4 +64,13 @@ public function configureFilters(Filters $filters): Filters
5864
->add(SemverFilter::new('tag'))
5965
;
6066
}
67+
68+
#[\Override]
69+
public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder
70+
{
71+
$qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters);
72+
SemverSort::apply($qb, 'tag');
73+
74+
return $qb;
75+
}
6176
}

src/Controller/Admin/InstallationCrudController.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,21 @@
99
use App\Admin\Field\RootDirField;
1010
use App\Admin\Field\ServerTypeField;
1111
use App\Admin\Field\VersionField;
12+
use App\Admin\SemverSort;
1213
use App\Entity\Installation;
1314
use App\Form\Type\Admin\FrameworkFilter;
1415
use App\Form\Type\Admin\SemverFilter;
1516
use App\Trait\ExportCrudControllerTrait;
17+
use Doctrine\ORM\QueryBuilder;
18+
use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection;
19+
use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection;
1620
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
1721
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
1822
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
1923
use EasyCorp\Bundle\EasyAdminBundle\Config\Filters;
2024
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
25+
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
26+
use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto;
2127
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
2228
use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField;
2329
use EasyCorp\Bundle\EasyAdminBundle\Field\CodeEditorField;
@@ -87,4 +93,13 @@ public function configureFilters(Filters $filters): Filters
8793
// ->add(SystemFilter::new('system')->mapped(false))
8894
;
8995
}
96+
97+
#[\Override]
98+
public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder
99+
{
100+
$qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters);
101+
SemverSort::apply($qb, 'frameworkVersion', 'composerVersion');
102+
103+
return $qb;
104+
}
90105
}

src/Controller/Admin/ModuleVersionCrudController.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,19 @@
55
namespace App\Controller\Admin;
66

77
use App\Admin\Field\VersionField;
8+
use App\Admin\SemverSort;
89
use App\Entity\ModuleVersion;
910
use App\Form\Type\Admin\SemverFilter;
11+
use Doctrine\ORM\QueryBuilder;
12+
use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection;
13+
use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection;
1014
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
1115
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
1216
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
1317
use EasyCorp\Bundle\EasyAdminBundle\Config\Filters;
1418
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
19+
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
20+
use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto;
1521
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
1622

1723
class ModuleVersionCrudController extends AbstractCrudController
@@ -55,4 +61,13 @@ public function configureFilters(Filters $filters): Filters
5561
->add(SemverFilter::new('version'))
5662
;
5763
}
64+
65+
#[\Override]
66+
public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder
67+
{
68+
$qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters);
69+
SemverSort::apply($qb, 'version');
70+
71+
return $qb;
72+
}
5873
}

src/Controller/Admin/PackageVersionCrudController.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,19 @@
77
use App\Admin\Field\AdvisoryCountField;
88
use App\Admin\Field\LatestStatusField;
99
use App\Admin\Field\VersionField;
10+
use App\Admin\SemverSort;
1011
use App\Entity\PackageVersion;
1112
use App\Form\Type\Admin\SemverFilter;
13+
use Doctrine\ORM\QueryBuilder;
14+
use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection;
15+
use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection;
1216
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
1317
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
1418
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
1519
use EasyCorp\Bundle\EasyAdminBundle\Config\Filters;
1620
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
21+
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
22+
use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto;
1723
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
1824
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
1925
use EasyCorp\Bundle\EasyAdminBundle\Field\UrlField;
@@ -69,4 +75,13 @@ public function configureFilters(Filters $filters): Filters
6975
->add('latestStatus')
7076
;
7177
}
78+
79+
#[\Override]
80+
public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder
81+
{
82+
$qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters);
83+
SemverSort::apply($qb, 'version', 'latest');
84+
85+
return $qb;
86+
}
7287
}

src/Controller/Admin/SiteCrudController.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,20 @@
1111
use App\Admin\Field\ServerTypeField;
1212
use App\Admin\Field\SiteTypeField;
1313
use App\Admin\Field\VersionField;
14+
use App\Admin\SemverSort;
1415
use App\Entity\Site;
1516
use App\Form\Type\Admin\SemverFilter;
1617
use App\Trait\ExportCrudControllerTrait;
18+
use Doctrine\ORM\QueryBuilder;
19+
use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection;
20+
use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection;
1721
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
1822
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
1923
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
2024
use EasyCorp\Bundle\EasyAdminBundle\Config\Filters;
2125
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
26+
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
27+
use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto;
2228
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
2329
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
2430

@@ -80,4 +86,13 @@ public function configureFilters(Filters $filters): Filters
8086
->add(SemverFilter::new('phpVersion', 'PHP'))
8187
->add('server');
8288
}
89+
90+
#[\Override]
91+
public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder
92+
{
93+
$qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters);
94+
SemverSort::apply($qb, 'phpVersion');
95+
96+
return $qb;
97+
}
8398
}

tests/Admin/SemverSortTest.php

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Tests\Admin;
6+
7+
use App\Admin\SemverSort;
8+
use App\Entity\Installation;
9+
use Doctrine\ORM\EntityManagerInterface;
10+
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
11+
12+
class SemverSortTest extends KernelTestCase
13+
{
14+
public function testRewritesMatchingPropertyToSemverNumeric(): void
15+
{
16+
$qb = $this->makeQb()->orderBy('entity.frameworkVersion', 'DESC');
17+
18+
SemverSort::apply($qb, 'frameworkVersion');
19+
20+
$parts = $qb->getDQLPart('orderBy');
21+
self::assertCount(1, $parts);
22+
self::assertSame('SEMVER_NUMERIC(entity.frameworkVersion) DESC', $parts[0]->getParts()[0]);
23+
}
24+
25+
public function testKeepsDirectionAndAddsAscDefaultWhenMissing(): void
26+
{
27+
$qb = $this->makeQb()->orderBy('entity.frameworkVersion');
28+
29+
SemverSort::apply($qb, 'frameworkVersion');
30+
31+
self::assertSame('SEMVER_NUMERIC(entity.frameworkVersion) ASC', $qb->getDQLPart('orderBy')[0]->getParts()[0]);
32+
}
33+
34+
public function testLeavesNonMatchingPropertiesUntouched(): void
35+
{
36+
$qb = $this->makeQb()
37+
->orderBy('entity.rootDir', 'ASC')
38+
->addOrderBy('entity.frameworkVersion', 'DESC');
39+
40+
SemverSort::apply($qb, 'frameworkVersion');
41+
42+
$allParts = [];
43+
foreach ($qb->getDQLPart('orderBy') as $orderBy) {
44+
$allParts = [...$allParts, ...$orderBy->getParts()];
45+
}
46+
self::assertSame(['entity.rootDir ASC', 'SEMVER_NUMERIC(entity.frameworkVersion) DESC'], $allParts);
47+
}
48+
49+
public function testRewritesMultipleListedProperties(): void
50+
{
51+
$qb = $this->makeQb()
52+
->orderBy('entity.frameworkVersion', 'DESC')
53+
->addOrderBy('entity.composerVersion', 'ASC');
54+
55+
SemverSort::apply($qb, 'frameworkVersion', 'composerVersion');
56+
57+
$allParts = [];
58+
foreach ($qb->getDQLPart('orderBy') as $orderBy) {
59+
$allParts = [...$allParts, ...$orderBy->getParts()];
60+
}
61+
self::assertSame(
62+
['SEMVER_NUMERIC(entity.frameworkVersion) DESC', 'SEMVER_NUMERIC(entity.composerVersion) ASC'],
63+
$allParts,
64+
);
65+
}
66+
67+
public function testIsNoopWhenNoOrderByPresent(): void
68+
{
69+
$qb = $this->makeQb();
70+
71+
SemverSort::apply($qb, 'frameworkVersion');
72+
73+
self::assertSame([], $qb->getDQLPart('orderBy'));
74+
}
75+
76+
private function makeQb(): \Doctrine\ORM\QueryBuilder
77+
{
78+
return $this->getEntityManager()->getRepository(Installation::class)->createQueryBuilder('entity');
79+
}
80+
81+
private function getEntityManager(): EntityManagerInterface
82+
{
83+
return static::getContainer()->get('doctrine')->getManager();
84+
}
85+
}

0 commit comments

Comments
 (0)