diff --git a/CHANGELOG.md b/CHANGELOG.md index acd3eda..c6b041b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index a62be68..68c9b7e 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/Admin/SemverSort.php b/src/Admin/SemverSort.php new file mode 100644 index 0000000..3276d33 --- /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 49f7855..068318d 100644 --- a/src/Controller/Admin/DockerImageTagCrudController.php +++ b/src/Controller/Admin/DockerImageTagCrudController.php @@ -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; @@ -16,6 +18,8 @@ class DockerImageTagCrudController extends AbstractCrudController { + use SemverSortableCrudControllerTrait; + public static function getEntityFqcn(): string { return DockerImageTag::class; @@ -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']; + } } diff --git a/src/Controller/Admin/GitTagCrudController.php b/src/Controller/Admin/GitTagCrudController.php index eceec7e..f715f1e 100644 --- a/src/Controller/Admin/GitTagCrudController.php +++ b/src/Controller/Admin/GitTagCrudController.php @@ -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; @@ -15,6 +17,8 @@ class GitTagCrudController extends AbstractCrudController { + use SemverSortableCrudControllerTrait; + public static function getEntityFqcn(): string { return GitTag::class; @@ -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']; + } } diff --git a/src/Controller/Admin/InstallationCrudController.php b/src/Controller/Admin/InstallationCrudController.php index e78a4cf..beb5c0e 100644 --- a/src/Controller/Admin/InstallationCrudController.php +++ b/src/Controller/Admin/InstallationCrudController.php @@ -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; @@ -25,6 +27,7 @@ class InstallationCrudController extends AbstractCrudController { use ExportCrudControllerTrait; + use SemverSortableCrudControllerTrait; public static function getEntityFqcn(): string { @@ -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']; + } } diff --git a/src/Controller/Admin/ModuleVersionCrudController.php b/src/Controller/Admin/ModuleVersionCrudController.php index 1d3f3e5..7db17aa 100644 --- a/src/Controller/Admin/ModuleVersionCrudController.php +++ b/src/Controller/Admin/ModuleVersionCrudController.php @@ -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; @@ -15,6 +17,8 @@ class ModuleVersionCrudController extends AbstractCrudController { + use SemverSortableCrudControllerTrait; + public static function getEntityFqcn(): string { return ModuleVersion::class; @@ -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']; + } } diff --git a/src/Controller/Admin/PackageVersionCrudController.php b/src/Controller/Admin/PackageVersionCrudController.php index a015c2b..68f521f 100644 --- a/src/Controller/Admin/PackageVersionCrudController.php +++ b/src/Controller/Admin/PackageVersionCrudController.php @@ -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; @@ -19,6 +21,8 @@ class PackageVersionCrudController extends AbstractCrudController { + use SemverSortableCrudControllerTrait; + public static function getEntityFqcn(): string { return PackageVersion::class; @@ -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']; + } } diff --git a/src/Controller/Admin/SiteCrudController.php b/src/Controller/Admin/SiteCrudController.php index 3380dab..aabaa03 100644 --- a/src/Controller/Admin/SiteCrudController.php +++ b/src/Controller/Admin/SiteCrudController.php @@ -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; @@ -24,6 +26,7 @@ class SiteCrudController extends AbstractCrudController { use ExportCrudControllerTrait; + use SemverSortableCrudControllerTrait; public function __construct() { @@ -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']; + } } diff --git a/src/Doctrine/Functions/SemverNumeric.php b/src/Doctrine/Functions/SemverNumeric.php new file mode 100644 index 0000000..50b06e4 --- /dev/null +++ b/src/Doctrine/Functions/SemverNumeric.php @@ -0,0 +1,71 @@ +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), + ); + } +} diff --git a/src/Form/Type/Admin/SemverFilter.php b/src/Form/Type/Admin/SemverFilter.php new file mode 100644 index 0000000..b8f092d --- /dev/null +++ b/src/Form/Type/Admin/SemverFilter.php @@ -0,0 +1,124 @@ +setFilterFqcn(self::class) + ->setProperty($propertyName) + ->setLabel($label) + ->setFormType(SemverFilterType::class); + } + + 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, 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 || !in_array($comparison, SemverFilterType::COMPARISON_CHOICES, true)) { + return; + } + + $isRange = SemverFilterType::COMPARISON_BETWEEN === $comparison + || SemverFilterType::COMPARISON_BETWEEN_EXCLUSIVE === $comparison; + if ($isRange && '' === $value2) { + return; + } + + try { + $parser = new VersionParser(); + $parser->normalize($value); + if ($isRange) { + $parser->normalize($value2); + } + } catch (\UnexpectedValueException) { + $queryBuilder->andWhere('1 = 0'); + + return; + } + + $alias = $filterDataDto->getEntityAlias(); + $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. + 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', + $alias, + $property, + $comparison, + $parameter, + )) + ->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/src/Form/Type/Admin/SemverFilterType.php b/src/Form/Type/Admin/SemverFilterType.php new file mode 100644 index 0000000..939df16 --- /dev/null +++ b/src/Form/Type/Admin/SemverFilterType.php @@ -0,0 +1,61 @@ +'; + 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, + '!=' => self::COMPARISON_NEQ, + '>' => self::COMPARISON_GT, + '>=' => self::COMPARISON_GTE, + '<' => self::COMPARISON_LT, + '<=' => self::COMPARISON_LTE, + 'between (inclusive)' => self::COMPARISON_BETWEEN, + 'between (exclusive)' => self::COMPARISON_BETWEEN_EXCLUSIVE, + ]; + + #[\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'], + ]) + ->add('value2', TextType::class, [ + 'required' => false, + 'attr' => ['placeholder' => 'upper bound (when between)'], + ]) + ; + } + + #[\Override] + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + ]); + } +} diff --git a/src/Trait/SemverSortableCrudControllerTrait.php b/src/Trait/SemverSortableCrudControllerTrait.php new file mode 100644 index 0000000..87b4b17 --- /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; + } +} diff --git a/tests/Admin/SemverSortTest.php b/tests/Admin/SemverSortTest.php new file mode 100644 index 0000000..34a9801 --- /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(); + } +} diff --git a/tests/Form/Type/Admin/SemverFilterTest.php b/tests/Form/Type/Admin/SemverFilterTest.php new file mode 100644 index 0000000..e95c721 --- /dev/null +++ b/tests/Form/Type/Admin/SemverFilterTest.php @@ -0,0 +1,143 @@ +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'); + } + + 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 + */ + 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, string $userValue2 = ''): 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, + 'value2' => $userValue2, + ]), + 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(); + } +}