From db48196e73351c00ad0c70676bdc6833f082bc2a Mon Sep 17 00:00:00 2001 From: Peter Gribanov Date: Tue, 28 Jul 2026 17:42:54 +0300 Subject: [PATCH 1/4] Restore backward compatibility with Doctrine ORM 2 PR #322 replaced the ORM constraint instead of widening it, which turned an additive feature into a BC break and would have forced a major release. Doctrine ORM 3 support is additive instead, matching how Symfony 7 support shipped in v2.1.0. All three src changes from #322 are already BC: - Type::hasType() available since DBAL 2.9.0 - enableResultCache() available since ORM 2.8.0 - toIterable() available since ORM 2.8.0 The ORM floor is ^2.17 rather than ^2.5 because the specs now double Doctrine\ORM\Query, which was final up to and including ORM 2.16 and only became non-final in 2.17.0. symfony/polyfill-php80 has to come back: src/Filter/Like.php calls str_starts_with(), str_ends_with() and str_contains(), which only exist on PHP 7.x through that polyfill. Also fixes the three failing test jobs: - PHP 8.4 (highest and lowest) could not resolve at all, since PHP 8.4 support only landed in phpspec ^8.0. - PHP 8.1 + lowest died with "'\static' is an invalid class name", because --prefer-lowest resolved prophecy 1.14.0 while support for the static return type only arrived in 1.15.0, and ORM 3 uses static on its fluent setters. phpspec 7.x only requires prophecy ^1.9, so the constraint has to be explicit. Restoring the PHP 7.2-8.0 matrix rows is also required for the 2.x branch protection to stay satisfiable: "PHP 7.4 + highest + normal" and "PHP 8.0 + highest + normal" are required status checks, and #322 deleted the jobs that report them. --- .github/workflows/test.yaml | 4 ++++ composer.json | 10 ++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 542f0d33..bfdf3dd9 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -14,6 +14,10 @@ jobs: - lowest - highest php-version: + - '7.2' + - '7.3' + - '7.4' + - '8.0' - '8.1' - '8.2' - '8.3' diff --git a/composer.json b/composer.json index ad1b4c4c..043e3b9f 100644 --- a/composer.json +++ b/composer.json @@ -20,12 +20,14 @@ } ], "require": { - "php": ">=8.1", - "doctrine/orm": "^3.0", - "symfony/property-access": "^5.2 || ^6.0 || ^7.0" + "php": ">=7.2", + "doctrine/orm": "^2.17 || ^3.0", + "symfony/property-access": "^4.0 || ^5.2 || ^6.0 || ^7.0", + "symfony/polyfill-php80": "^1.20" }, "require-dev": { - "phpspec/phpspec": "~6.3 || ^7.0" + "phpspec/phpspec": "~6.3 || ^7.0 || ^8.0", + "phpspec/prophecy": "^1.15" }, "autoload": { "psr-4": { From 355c0b8417c0b6943f42a3ee3af3b7cbaf0f3294 Mon Sep 17 00:00:00 2001 From: Peter Gribanov Date: Tue, 28 Jul 2026 17:44:58 +0300 Subject: [PATCH 2/4] Fix PHPStan findings These are pre-existing and unrelated to ORM 3: the QA job installs whatever phpstan is current, so the analysis got stricter over time while the code stood still. On a clean 2.x, phpstan 1.12 reported 3 errors and phpstan 2.2 reported 8. - EntityRepository declares @template T of object, so passing it an unbounded T resolved to mixed. Bounding both templates fixes all three generics errors at once. - explode() never returns an empty array, so array_pop() always yields a string and the null check was dead. - substr() does not return false on PHP 8. Only the PHPDoc is narrowed; adding ": string" to the signature would break subclasses by covariance. - treatPhpDocTypesAsCertain: false silences two instanceof.alwaysTrue reports in QueryModifierCollection and ResultModifierCollection. Those guards are real - they catch untyped callers and throw InvalidArgumentException - so the check stays and the report goes. phpstan 2.2 now reports no errors against ORM 3.6.7 and ORM 2.20.13. It still reports one generics error against ORM 2.17-2.19, where getClassMetadata() is annotated with @psalm-return and @psalm-template that PHPStan does not read; ORM 2.20 changed those to @template and @return. The QA job analyses the highest dependency set, so this does not affect CI. --- phpstan.neon.dist | 1 + src/DBALTypesResolver.php | 2 +- src/Operand/PlatformFunction/Executor/SubstringExecutor.php | 2 +- src/Repository/EntitySpecificationRepository.php | 2 +- src/Repository/RepositoryFactory.php | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 1ff472c7..f6c9ddc0 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,4 +1,5 @@ parameters: level: 8 + treatPhpDocTypesAsCertain: false paths: - src diff --git a/src/DBALTypesResolver.php b/src/DBALTypesResolver.php index 60aaaf90..f0028a82 100644 --- a/src/DBALTypesResolver.php +++ b/src/DBALTypesResolver.php @@ -52,7 +52,7 @@ public static function tryGetTypeForValue($value): ?Type $classNameParts = explode('\\', str_replace('_', '\\', $className)); $typeName = array_pop($classNameParts); - if (null !== $typeName && Type::hasType($typeName)) { + if (Type::hasType($typeName)) { return Type::getType($typeName); } diff --git a/src/Operand/PlatformFunction/Executor/SubstringExecutor.php b/src/Operand/PlatformFunction/Executor/SubstringExecutor.php index 4b0c67ab..2db3cf73 100644 --- a/src/Operand/PlatformFunction/Executor/SubstringExecutor.php +++ b/src/Operand/PlatformFunction/Executor/SubstringExecutor.php @@ -21,7 +21,7 @@ final class SubstringExecutor * @param int $offset * @param int|null $length * - * @return false|string + * @return string */ public function __invoke(string $string, int $offset, ?int $length = null) { diff --git a/src/Repository/EntitySpecificationRepository.php b/src/Repository/EntitySpecificationRepository.php index 9bde7d10..7cf15408 100644 --- a/src/Repository/EntitySpecificationRepository.php +++ b/src/Repository/EntitySpecificationRepository.php @@ -19,7 +19,7 @@ /** * This class allows you to use a Specification to query entities. * - * @template T + * @template T of object * @phpstan-extends EntityRepository */ class EntitySpecificationRepository extends EntityRepository implements EntitySpecificationRepositoryInterface diff --git a/src/Repository/RepositoryFactory.php b/src/Repository/RepositoryFactory.php index 04c68130..ecc706cf 100644 --- a/src/Repository/RepositoryFactory.php +++ b/src/Repository/RepositoryFactory.php @@ -34,7 +34,7 @@ class RepositoryFactory implements RepositoryFactoryInterface * * @return EntityRepository * - * @phpstan-template T + * @phpstan-template T of object * @phpstan-param class-string $entityName * @phpstan-return EntityRepository */ From 41eb2962a53bd0adde1d5d42c79b8415412fa5d9 Mon Sep 17 00:00:00 2001 From: Peter Gribanov Date: Tue, 28 Jul 2026 17:45:25 +0300 Subject: [PATCH 3/4] Apply PHP CS Fixer Also pre-existing and unrelated to ORM 3. The QA job installs whatever php-cs-fixer is current, and the @PhpCsFixer ruleset has grown rules since the last sweep: static_lambda, single_line_empty_body, no_extra_blank_lines, whitespace_after_comma_in_array, ordered_imports, phpdoc_separation and a few more. On a clean 2.x this reported 40 files; #322 had already brought that down to 31 by reformatting the tests it touched. No behaviour change. The specs still pass 527/527 and phpstan is still clean. Running the fixer a second time reports nothing, so the tree is now a fixed point of the current ruleset. --- src/Exception/InvalidArgumentException.php | 4 +--- src/Exception/LogicException.php | 4 +--- src/Exception/NoResultException.php | 4 +--- src/Exception/NonUniqueResultException.php | 4 +--- src/Exception/OperandNotExecuteException.php | 4 +--- .../PlatformFunctionExecutorException.php | 4 +--- src/Exception/UnexpectedResultException.php | 4 +--- src/Filter/Like.php | 2 +- src/Filter/Satisfiable.php | 6 ++++-- src/Operand/ArgumentToOperandConverter.php | 2 +- src/Operand/PlatformFunction/DateSub.php | 1 + .../PlatformFunctionExecutorRegistry.php | 2 ++ src/Query/IndexBy.php | 2 +- .../EntitySpecificationRepository.php | 1 + src/Repository/RepositoryFactory.php | 6 ++++-- src/Specification/Specification.php | 4 +--- tests/DQLContextResolverSpec.php | 10 +++++----- tests/Filter/InstanceOfXSpec.php | 4 ++-- tests/Filter/IsEmptySpec.php | 20 +++++++++---------- tests/Filter/IsNotNullSpec.php | 20 +++++++++---------- tests/Filter/IsNullSpec.php | 20 +++++++++---------- .../ArgumentToOperandConverterSpec.php | 8 ++++---- .../Operand/PlatformFunction/DateAddSpec.php | 6 +++--- .../Operand/PlatformFunction/DateSubSpec.php | 6 +++--- .../Executor/CurrentDateExecutorSpec.php | 4 ++-- .../Executor/CurrentTimeExecutorSpec.php | 4 ++-- .../Executor/CurrentTimestampExecutorSpec.php | 4 ++-- .../Executor/DateDiffExecutorSpec.php | 5 ++--- .../PlatformFunctionExecutorRegistrySpec.php | 10 +++++----- .../EntitySpecificationRepositorySpec.php | 2 +- tests/Specification/CountOfSpec.php | 2 +- 31 files changed, 85 insertions(+), 94 deletions(-) diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php index 66dc63b9..a90bbf19 100644 --- a/src/Exception/InvalidArgumentException.php +++ b/src/Exception/InvalidArgumentException.php @@ -14,6 +14,4 @@ namespace Happyr\DoctrineSpecification\Exception; -final class InvalidArgumentException extends \InvalidArgumentException -{ -} +final class InvalidArgumentException extends \InvalidArgumentException {} diff --git a/src/Exception/LogicException.php b/src/Exception/LogicException.php index 91ee3703..5696970f 100644 --- a/src/Exception/LogicException.php +++ b/src/Exception/LogicException.php @@ -14,6 +14,4 @@ namespace Happyr\DoctrineSpecification\Exception; -final class LogicException extends \LogicException -{ -} +final class LogicException extends \LogicException {} diff --git a/src/Exception/NoResultException.php b/src/Exception/NoResultException.php index 269e38e5..c3699db8 100644 --- a/src/Exception/NoResultException.php +++ b/src/Exception/NoResultException.php @@ -14,6 +14,4 @@ namespace Happyr\DoctrineSpecification\Exception; -final class NoResultException extends UnexpectedResultException -{ -} +final class NoResultException extends UnexpectedResultException {} diff --git a/src/Exception/NonUniqueResultException.php b/src/Exception/NonUniqueResultException.php index b51ed991..70b1b649 100644 --- a/src/Exception/NonUniqueResultException.php +++ b/src/Exception/NonUniqueResultException.php @@ -14,6 +14,4 @@ namespace Happyr\DoctrineSpecification\Exception; -final class NonUniqueResultException extends UnexpectedResultException -{ -} +final class NonUniqueResultException extends UnexpectedResultException {} diff --git a/src/Exception/OperandNotExecuteException.php b/src/Exception/OperandNotExecuteException.php index 817e874e..1c04dd75 100644 --- a/src/Exception/OperandNotExecuteException.php +++ b/src/Exception/OperandNotExecuteException.php @@ -14,6 +14,4 @@ namespace Happyr\DoctrineSpecification\Exception; -final class OperandNotExecuteException extends \RuntimeException -{ -} +final class OperandNotExecuteException extends \RuntimeException {} diff --git a/src/Exception/PlatformFunctionExecutorException.php b/src/Exception/PlatformFunctionExecutorException.php index 3ccba75f..da2006c4 100644 --- a/src/Exception/PlatformFunctionExecutorException.php +++ b/src/Exception/PlatformFunctionExecutorException.php @@ -14,6 +14,4 @@ namespace Happyr\DoctrineSpecification\Exception; -final class PlatformFunctionExecutorException extends \InvalidArgumentException -{ -} +final class PlatformFunctionExecutorException extends \InvalidArgumentException {} diff --git a/src/Exception/UnexpectedResultException.php b/src/Exception/UnexpectedResultException.php index 1eb67d36..9c9824e3 100644 --- a/src/Exception/UnexpectedResultException.php +++ b/src/Exception/UnexpectedResultException.php @@ -14,6 +14,4 @@ namespace Happyr\DoctrineSpecification\Exception; -abstract class UnexpectedResultException extends \RuntimeException -{ -} +abstract class UnexpectedResultException extends \RuntimeException {} diff --git a/src/Filter/Like.php b/src/Filter/Like.php index 0a526fa7..3f72b4b1 100644 --- a/src/Filter/Like.php +++ b/src/Filter/Like.php @@ -51,7 +51,7 @@ final class Like implements Filter, Satisfiable */ public function __construct($field, $value, string $format = LikePattern::CONTAINS, ?string $context = null) { - if (!($value instanceof LikePattern)) { + if (!$value instanceof LikePattern) { $value = new LikePattern($value, $format); } diff --git a/src/Filter/Satisfiable.php b/src/Filter/Satisfiable.php index ee8365d6..f43bd487 100644 --- a/src/Filter/Satisfiable.php +++ b/src/Filter/Satisfiable.php @@ -20,10 +20,12 @@ interface Satisfiable * @param iterable $collection * @param string|null $context * - * @return iterable - * * @phpstan-template T + * * @phpstan-param iterable $collection + * + * @return iterable + * * @phpstan-return iterable */ public function filterCollection(iterable $collection, ?string $context = null): iterable; diff --git a/src/Operand/ArgumentToOperandConverter.php b/src/Operand/ArgumentToOperandConverter.php index 277467d7..209e5c14 100644 --- a/src/Operand/ArgumentToOperandConverter.php +++ b/src/Operand/ArgumentToOperandConverter.php @@ -61,7 +61,7 @@ public static function toValue($argument): Operand public static function isAllOperands(array $arguments): bool { foreach ($arguments as $argument) { - if (!($argument instanceof Operand)) { + if (!$argument instanceof Operand) { return false; } } diff --git a/src/Operand/PlatformFunction/DateSub.php b/src/Operand/PlatformFunction/DateSub.php index 886e73e3..dd789851 100644 --- a/src/Operand/PlatformFunction/DateSub.php +++ b/src/Operand/PlatformFunction/DateSub.php @@ -122,6 +122,7 @@ public function execute($candidate, ?string $context = null): \DateTimeImmutable if (0 > (float) $value) { return $new_date->modify(sprintf('+%d %s', abs((float) $value), $this->unit)); } + return $new_date->modify(sprintf('-%d %s', $value, $this->unit)); } } diff --git a/src/Operand/PlatformFunction/Executor/PlatformFunctionExecutorRegistry.php b/src/Operand/PlatformFunction/Executor/PlatformFunctionExecutorRegistry.php index 0073660e..48dcf27d 100644 --- a/src/Operand/PlatformFunction/Executor/PlatformFunctionExecutorRegistry.php +++ b/src/Operand/PlatformFunction/Executor/PlatformFunctionExecutorRegistry.php @@ -20,12 +20,14 @@ final class PlatformFunctionExecutorRegistry { /** * @var callable[] + * * @phpstan-var array */ private $executors; /** * @param callable[] $executors + * * @phpstan-param array $executors */ public function __construct(array $executors) diff --git a/src/Query/IndexBy.php b/src/Query/IndexBy.php index 4660748a..b4acc5c7 100644 --- a/src/Query/IndexBy.php +++ b/src/Query/IndexBy.php @@ -40,7 +40,7 @@ final class IndexBy implements QueryModifier */ public function __construct($field, ?string $context = null) { - if (!($field instanceof Field)) { + if (!$field instanceof Field) { $field = new Field($field); } diff --git a/src/Repository/EntitySpecificationRepository.php b/src/Repository/EntitySpecificationRepository.php index 7cf15408..80ea9bfd 100644 --- a/src/Repository/EntitySpecificationRepository.php +++ b/src/Repository/EntitySpecificationRepository.php @@ -20,6 +20,7 @@ * This class allows you to use a Specification to query entities. * * @template T of object + * * @phpstan-extends EntityRepository */ class EntitySpecificationRepository extends EntityRepository implements EntitySpecificationRepositoryInterface diff --git a/src/Repository/RepositoryFactory.php b/src/Repository/RepositoryFactory.php index ecc706cf..22158aba 100644 --- a/src/Repository/RepositoryFactory.php +++ b/src/Repository/RepositoryFactory.php @@ -32,10 +32,12 @@ class RepositoryFactory implements RepositoryFactoryInterface * @param EntityManagerInterface $entityManager the EntityManager instance * @param string $entityName the name of the entity * - * @return EntityRepository - * * @phpstan-template T of object + * * @phpstan-param class-string $entityName + * + * @return EntityRepository + * * @phpstan-return EntityRepository */ public function getRepository(EntityManagerInterface $entityManager, $entityName): EntityRepository diff --git a/src/Specification/Specification.php b/src/Specification/Specification.php index 2138cb0a..43e20e7d 100644 --- a/src/Specification/Specification.php +++ b/src/Specification/Specification.php @@ -18,6 +18,4 @@ use Happyr\DoctrineSpecification\Filter\Satisfiable; use Happyr\DoctrineSpecification\Query\QueryModifier; -interface Specification extends Filter, QueryModifier, Satisfiable -{ -} +interface Specification extends Filter, QueryModifier, Satisfiable {} diff --git a/tests/DQLContextResolverSpec.php b/tests/DQLContextResolverSpec.php index 9413dbcd..5a0773bc 100644 --- a/tests/DQLContextResolverSpec.php +++ b/tests/DQLContextResolverSpec.php @@ -116,10 +116,10 @@ public function it_resolve_conflict(QueryBuilder $qb): void new Join(Join::INNER_JOIN, 'foo.bar', 'contestant'), ], ]); - $qb->join('root.contestant', Argument::that(function ($argument) { + $qb->join('root.contestant', Argument::that(static function ($argument) { return preg_match('/^contestant[a-f0-9]+/', $argument); }))->willReturn($qb); - $qb->join(Argument::that(function ($argument) { + $qb->join(Argument::that(static function ($argument) { return preg_match('/^contestant[a-f0-9]+\.contest$/', $argument); }), 'contest')->willReturn($qb); @@ -132,12 +132,12 @@ public function it_join_always_unique_alias(QueryBuilder $qb): void $qb->getAllAliases()->willReturn([]); $qb->getDQLPart('join')->willReturn([]); - $qb->join('root.contestant', Argument::that(function ($argument) { + $qb->join('root.contestant', Argument::that(static function ($argument) { return preg_match('/^contestant[a-f0-9]+/', $argument); }))->willReturn($qb); - $qb->join(Argument::that(function ($argument) { + $qb->join(Argument::that(static function ($argument) { return preg_match('/^contestant[a-f0-9]+\.contest$/', $argument); - }), Argument::that(function ($argument) { + }), Argument::that(static function ($argument) { return preg_match('/^contest[a-f0-9]+$/', $argument); }))->willReturn($qb); diff --git a/tests/Filter/InstanceOfXSpec.php b/tests/Filter/InstanceOfXSpec.php index d8d25259..543015f7 100644 --- a/tests/Filter/InstanceOfXSpec.php +++ b/tests/Filter/InstanceOfXSpec.php @@ -74,8 +74,8 @@ public function it_filter_array_collection(): void $this->beConstructedWith(Player::class, null); $players = [ - ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500], - ['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230], + ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500], + ['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230], ['pseudo' => 'Alice', 'gender' => 'F', 'points' => 9001], ]; diff --git a/tests/Filter/IsEmptySpec.php b/tests/Filter/IsEmptySpec.php index fb494f0d..ede14e3a 100644 --- a/tests/Filter/IsEmptySpec.php +++ b/tests/Filter/IsEmptySpec.php @@ -65,8 +65,8 @@ public function it_filter_array_collection(): void $this->beConstructedWith('points', null); $players = [ - ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500], - ['pseudo' => 'Moe', 'gender' => 'M', 'points' => null], + ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500], + ['pseudo' => 'Moe', 'gender' => 'M', 'points' => null], ['pseudo' => 'Alice', 'gender' => 'F', 'points' => 9001], ]; @@ -90,8 +90,8 @@ public function it_is_satisfied_with_array(): void { $this->beConstructedWith('points', null); - $playerA = ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500]; - $playerB = ['pseudo' => 'Moe', 'gender' => 'M', 'points' => null]; + $playerA = ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500]; + $playerB = ['pseudo' => 'Moe', 'gender' => 'M', 'points' => null]; $playerC = ['pseudo' => 'Alice', 'gender' => 'F', 'points' => 9001]; $this->isSatisfiedBy($playerA)->shouldBe(false); @@ -138,8 +138,8 @@ public function it_filter_array_collection_in_context(): void $tetris = ['name' => 'Tetris', 'releaseAt' => null]; $mahjong = ['name' => 'Mahjong', 'releaseAt' => $releaseAt]; $players = [ - ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500, 'inGame' => $mahjong], - ['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230, 'inGame' => $mahjong], + ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500, 'inGame' => $mahjong], + ['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230, 'inGame' => $mahjong], ['pseudo' => 'Alice', 'gender' => 'F', 'points' => 9001, 'inGame' => $tetris], ]; @@ -154,8 +154,8 @@ public function it_filter_array_collection_in_global_context(): void $tetris = ['name' => 'Tetris', 'releaseAt' => null]; $mahjong = ['name' => 'Mahjong', 'releaseAt' => $releaseAt]; $players = [ - ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500, 'inGame' => $mahjong], - ['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230, 'inGame' => $mahjong], + ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500, 'inGame' => $mahjong], + ['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230, 'inGame' => $mahjong], ['pseudo' => 'Alice', 'gender' => 'F', 'points' => 9001, 'inGame' => $tetris], ]; @@ -181,8 +181,8 @@ public function it_filter_array_collection_in_combo_context(): void $tetris = ['name' => 'Tetris', 'owner' => $tetrisOwner]; $mahjong = ['name' => 'Mahjong', 'owner' => $mahjongOwner]; $players = [ - ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500, 'inGame' => $mahjong], - ['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230, 'inGame' => $mahjong], + ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500, 'inGame' => $mahjong], + ['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230, 'inGame' => $mahjong], ['pseudo' => 'Alice', 'gender' => 'F', 'points' => 9001, 'inGame' => $tetris], ]; diff --git a/tests/Filter/IsNotNullSpec.php b/tests/Filter/IsNotNullSpec.php index 4574b1ce..c9752d92 100644 --- a/tests/Filter/IsNotNullSpec.php +++ b/tests/Filter/IsNotNullSpec.php @@ -71,8 +71,8 @@ public function it_filter_array_collection(): void $this->beConstructedWith('points', null); $players = [ - ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500], - ['pseudo' => 'Moe', 'gender' => 'M', 'points' => null], + ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500], + ['pseudo' => 'Moe', 'gender' => 'M', 'points' => null], ['pseudo' => 'Alice', 'gender' => 'F', 'points' => 9001], ]; @@ -96,8 +96,8 @@ public function it_is_satisfied_with_array(): void { $this->beConstructedWith('points', null); - $playerA = ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500]; - $playerB = ['pseudo' => 'Moe', 'gender' => 'M', 'points' => null]; + $playerA = ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500]; + $playerB = ['pseudo' => 'Moe', 'gender' => 'M', 'points' => null]; $playerC = ['pseudo' => 'Alice', 'gender' => 'F', 'points' => 9001]; $this->isSatisfiedBy($playerA)->shouldBe(true); @@ -146,8 +146,8 @@ public function it_filter_array_collection_in_context(): void $tetris = ['name' => 'Tetris', 'releaseAt' => $releaseAt]; $mahjong = ['name' => 'Mahjong', 'releaseAt' => null]; $players = [ - ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500, 'inGame' => $mahjong], - ['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230, 'inGame' => $mahjong], + ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500, 'inGame' => $mahjong], + ['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230, 'inGame' => $mahjong], ['pseudo' => 'Alice', 'gender' => 'F', 'points' => 9001, 'inGame' => $tetris], ]; @@ -162,8 +162,8 @@ public function it_filter_array_collection_in_global_context(): void $tetris = ['name' => 'Tetris', 'releaseAt' => $releaseAt]; $mahjong = ['name' => 'Mahjong', 'releaseAt' => null]; $players = [ - ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500, 'inGame' => $mahjong], - ['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230, 'inGame' => $mahjong], + ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500, 'inGame' => $mahjong], + ['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230, 'inGame' => $mahjong], ['pseudo' => 'Alice', 'gender' => 'F', 'points' => 9001, 'inGame' => $tetris], ]; @@ -190,8 +190,8 @@ public function it_filter_array_collection_in_combo_context(): void $tetris = ['name' => 'Tetris', 'owner' => $tetrisOwner]; $mahjong = ['name' => 'Mahjong', 'owner' => $mahjongOwner]; $players = [ - ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500, 'inGame' => $mahjong], - ['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230, 'inGame' => $mahjong], + ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500, 'inGame' => $mahjong], + ['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230, 'inGame' => $mahjong], ['pseudo' => 'Alice', 'gender' => 'F', 'points' => 9001, 'inGame' => $tetris], ]; diff --git a/tests/Filter/IsNullSpec.php b/tests/Filter/IsNullSpec.php index 6b643c8c..6b81b237 100644 --- a/tests/Filter/IsNullSpec.php +++ b/tests/Filter/IsNullSpec.php @@ -71,8 +71,8 @@ public function it_filter_array_collection(): void $this->beConstructedWith('points', null); $players = [ - ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500], - ['pseudo' => 'Moe', 'gender' => 'M', 'points' => null], + ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500], + ['pseudo' => 'Moe', 'gender' => 'M', 'points' => null], ['pseudo' => 'Alice', 'gender' => 'F', 'points' => 9001], ]; @@ -96,8 +96,8 @@ public function it_is_satisfied_with_array(): void { $this->beConstructedWith('points', null); - $playerA = ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500]; - $playerB = ['pseudo' => 'Moe', 'gender' => 'M', 'points' => null]; + $playerA = ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500]; + $playerB = ['pseudo' => 'Moe', 'gender' => 'M', 'points' => null]; $playerC = ['pseudo' => 'Alice', 'gender' => 'F', 'points' => 9001]; $this->isSatisfiedBy($playerA)->shouldBe(false); @@ -144,8 +144,8 @@ public function it_filter_array_collection_in_context(): void $tetris = ['name' => 'Tetris', 'releaseAt' => null]; $mahjong = ['name' => 'Mahjong', 'releaseAt' => $releaseAt]; $players = [ - ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500, 'inGame' => $mahjong], - ['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230, 'inGame' => $mahjong], + ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500, 'inGame' => $mahjong], + ['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230, 'inGame' => $mahjong], ['pseudo' => 'Alice', 'gender' => 'F', 'points' => 9001, 'inGame' => $tetris], ]; @@ -160,8 +160,8 @@ public function it_filter_array_collection_in_global_context(): void $tetris = ['name' => 'Tetris', 'releaseAt' => null]; $mahjong = ['name' => 'Mahjong', 'releaseAt' => $releaseAt]; $players = [ - ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500, 'inGame' => $mahjong], - ['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230, 'inGame' => $mahjong], + ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500, 'inGame' => $mahjong], + ['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230, 'inGame' => $mahjong], ['pseudo' => 'Alice', 'gender' => 'F', 'points' => 9001, 'inGame' => $tetris], ]; @@ -187,8 +187,8 @@ public function it_filter_array_collection_in_combo_context(): void $tetris = ['name' => 'Tetris', 'owner' => $tetrisOwner]; $mahjong = ['name' => 'Mahjong', 'owner' => $mahjongOwner]; $players = [ - ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500, 'inGame' => $mahjong], - ['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230, 'inGame' => $mahjong], + ['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500, 'inGame' => $mahjong], + ['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230, 'inGame' => $mahjong], ['pseudo' => 'Alice', 'gender' => 'F', 'points' => 9001, 'inGame' => $tetris], ]; diff --git a/tests/Operand/ArgumentToOperandConverterSpec.php b/tests/Operand/ArgumentToOperandConverterSpec.php index 783fdabb..42850a28 100644 --- a/tests/Operand/ArgumentToOperandConverterSpec.php +++ b/tests/Operand/ArgumentToOperandConverterSpec.php @@ -128,16 +128,16 @@ public function it_a_convertible_arguments2(Operand $operand): void public function getMatchers(): array { return [ - 'haveField' => function ($subject): bool { + 'haveField' => static function ($subject): bool { return $subject[0] instanceof Field; }, - 'haveValue' => function ($subject): bool { + 'haveValue' => static function ($subject): bool { return $subject[count($subject) - 1] instanceof Value; }, - 'haveValueAt' => function ($subject, $position): bool { + 'haveValueAt' => static function ($subject, $position): bool { return $subject[$position] instanceof Value; }, - 'haveOperandAt' => function ($subject, $position): bool { + 'haveOperandAt' => static function ($subject, $position): bool { return $subject[$position] instanceof Operand; }, ]; diff --git a/tests/Operand/PlatformFunction/DateAddSpec.php b/tests/Operand/PlatformFunction/DateAddSpec.php index e387a70b..42e3811b 100644 --- a/tests/Operand/PlatformFunction/DateAddSpec.php +++ b/tests/Operand/PlatformFunction/DateAddSpec.php @@ -207,13 +207,13 @@ public function it_should_sub_one_second(): void public function getMatchers(): array { return [ - 'returnInstanceOf' => function (\DateTimeInterface $subject, string $expected): bool { + 'returnInstanceOf' => static function (\DateTimeInterface $subject, string $expected): bool { return $subject instanceof $expected; }, - 'returnSameTimeZone' => function (\DateTimeInterface $subject, \DateTimeZone $expected): bool { + 'returnSameTimeZone' => static function (\DateTimeInterface $subject, \DateTimeZone $expected): bool { return $subject->getTimezone()->getName() === $expected->getName(); }, - 'returnSameTimestamp' => function (\DateTimeInterface $subject, \DateTimeInterface $expected): bool { + 'returnSameTimestamp' => static function (\DateTimeInterface $subject, \DateTimeInterface $expected): bool { return $subject->getTimestamp() === $expected->getTimestamp(); }, ]; diff --git a/tests/Operand/PlatformFunction/DateSubSpec.php b/tests/Operand/PlatformFunction/DateSubSpec.php index 5f14fbd9..a39561a1 100644 --- a/tests/Operand/PlatformFunction/DateSubSpec.php +++ b/tests/Operand/PlatformFunction/DateSubSpec.php @@ -207,13 +207,13 @@ public function it_should_sub_one_second(): void public function getMatchers(): array { return [ - 'returnInstanceOf' => function (\DateTimeInterface $subject, string $expected): bool { + 'returnInstanceOf' => static function (\DateTimeInterface $subject, string $expected): bool { return $subject instanceof $expected; }, - 'returnSameTimeZone' => function (\DateTimeInterface $subject, \DateTimeZone $expected): bool { + 'returnSameTimeZone' => static function (\DateTimeInterface $subject, \DateTimeZone $expected): bool { return $subject->getTimezone()->getName() === $expected->getName(); }, - 'returnSameTimestamp' => function (\DateTimeInterface $subject, \DateTimeInterface $expected): bool { + 'returnSameTimestamp' => static function (\DateTimeInterface $subject, \DateTimeInterface $expected): bool { return $subject->getTimestamp() === $expected->getTimestamp(); }, ]; diff --git a/tests/Operand/PlatformFunction/Executor/CurrentDateExecutorSpec.php b/tests/Operand/PlatformFunction/Executor/CurrentDateExecutorSpec.php index 02729444..9d0daadd 100644 --- a/tests/Operand/PlatformFunction/Executor/CurrentDateExecutorSpec.php +++ b/tests/Operand/PlatformFunction/Executor/CurrentDateExecutorSpec.php @@ -32,10 +32,10 @@ public function it_should_return_current_date(): void public function getMatchers(): array { return [ - 'beWithDefaultTimeZone' => function (\DateTimeInterface $subject): bool { + 'beWithDefaultTimeZone' => static function (\DateTimeInterface $subject): bool { return $subject->getTimezone()->getName() === date_default_timezone_get(); }, - 'beCurrentDate' => function (\DateTimeInterface $subject): bool { + 'beCurrentDate' => static function (\DateTimeInterface $subject): bool { return $subject->getTimestamp() === (new \DateTimeImmutable())->setTime(0, 0)->getTimestamp(); }, ]; diff --git a/tests/Operand/PlatformFunction/Executor/CurrentTimeExecutorSpec.php b/tests/Operand/PlatformFunction/Executor/CurrentTimeExecutorSpec.php index 8124758a..51c4fd03 100644 --- a/tests/Operand/PlatformFunction/Executor/CurrentTimeExecutorSpec.php +++ b/tests/Operand/PlatformFunction/Executor/CurrentTimeExecutorSpec.php @@ -32,10 +32,10 @@ public function it_should_return_current_time(): void public function getMatchers(): array { return [ - 'beWithDefaultTimeZone' => function (\DateTimeInterface $subject): bool { + 'beWithDefaultTimeZone' => static function (\DateTimeInterface $subject): bool { return $subject->getTimezone()->getName() === date_default_timezone_get(); }, - 'beCurrentTime' => function (\DateTimeInterface $subject): bool { + 'beCurrentTime' => static function (\DateTimeInterface $subject): bool { return $subject->getTimestamp() === (new \DateTimeImmutable())->setDate(1, 1, 1)->getTimestamp(); }, ]; diff --git a/tests/Operand/PlatformFunction/Executor/CurrentTimestampExecutorSpec.php b/tests/Operand/PlatformFunction/Executor/CurrentTimestampExecutorSpec.php index 796b4ecc..57a48309 100644 --- a/tests/Operand/PlatformFunction/Executor/CurrentTimestampExecutorSpec.php +++ b/tests/Operand/PlatformFunction/Executor/CurrentTimestampExecutorSpec.php @@ -32,10 +32,10 @@ public function it_should_return_current_timestamp(): void public function getMatchers(): array { return [ - 'beWithDefaultTimeZone' => function (\DateTimeInterface $subject): bool { + 'beWithDefaultTimeZone' => static function (\DateTimeInterface $subject): bool { return $subject->getTimezone()->getName() === date_default_timezone_get(); }, - 'beCurrentTimestamp' => function (\DateTimeInterface $subject): bool { + 'beCurrentTimestamp' => static function (\DateTimeInterface $subject): bool { return $subject->getTimestamp() === (new \DateTimeImmutable())->getTimestamp(); }, ]; diff --git a/tests/Operand/PlatformFunction/Executor/DateDiffExecutorSpec.php b/tests/Operand/PlatformFunction/Executor/DateDiffExecutorSpec.php index 0209f871..8b195294 100644 --- a/tests/Operand/PlatformFunction/Executor/DateDiffExecutorSpec.php +++ b/tests/Operand/PlatformFunction/Executor/DateDiffExecutorSpec.php @@ -33,7 +33,7 @@ public function it_should_make_a_date_diff(): void public function getMatchers(): array { return [ - 'beSameDateInterval' => function (\DateInterval $subject, \DateInterval $expected): bool { + 'beSameDateInterval' => static function (\DateInterval $subject, \DateInterval $expected): bool { return $subject->y === $expected->y && $subject->m === $expected->m && @@ -41,8 +41,7 @@ public function getMatchers(): array $subject->h === $expected->h && $subject->i === $expected->i && $subject->s === $expected->s && - $subject->invert === $expected->invert - ; + $subject->invert === $expected->invert; }, ]; } diff --git a/tests/Operand/PlatformFunction/Executor/PlatformFunctionExecutorRegistrySpec.php b/tests/Operand/PlatformFunction/Executor/PlatformFunctionExecutorRegistrySpec.php index fbcdf293..7e526da0 100644 --- a/tests/Operand/PlatformFunction/Executor/PlatformFunctionExecutorRegistrySpec.php +++ b/tests/Operand/PlatformFunction/Executor/PlatformFunctionExecutorRegistrySpec.php @@ -32,7 +32,7 @@ public function let(): void public function it_should_register_executor(): void { - $executor = function (int $x, int $y): int { + $executor = static function (int $x, int $y): int { return $x + $y; }; @@ -43,7 +43,7 @@ public function it_should_register_executor(): void public function it_should_throw_exception_on_register_exist_executor(): void { - $executor = function ($x) { + $executor = static function ($x) { return $x > 0 ? $x : $x * -1; }; @@ -52,7 +52,7 @@ public function it_should_throw_exception_on_register_exist_executor(): void public function it_should_override_executor(): void { - $executor = function ($x) { + $executor = static function ($x) { return $x > 0 ? $x : $x * -1; }; @@ -63,7 +63,7 @@ public function it_should_override_executor(): void public function it_should_throw_exception_on_override_undefined_executor(): void { - $executor = function (int $x, int $y): int { + $executor = static function (int $x, int $y): int { return $x + $y; }; @@ -78,7 +78,7 @@ public function it_should_execute(): void public function it_should_execute_custom_executor(): void { - $executor = function (int $x, int $y): int { + $executor = static function (int $x, int $y): int { return $x + $y; }; diff --git a/tests/Repository/EntitySpecificationRepositorySpec.php b/tests/Repository/EntitySpecificationRepositorySpec.php index 7b9b3bd6..118b3f60 100644 --- a/tests/Repository/EntitySpecificationRepositorySpec.php +++ b/tests/Repository/EntitySpecificationRepositorySpec.php @@ -16,9 +16,9 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; -use Doctrine\ORM\Query; use Doctrine\ORM\NonUniqueResultException as DoctrineNonUniqueResultException; use Doctrine\ORM\NoResultException as DoctrineNoResultException; +use Doctrine\ORM\Query; use Doctrine\ORM\QueryBuilder; use Happyr\DoctrineSpecification\Exception\NonUniqueResultException; use Happyr\DoctrineSpecification\Exception\NoResultException; diff --git a/tests/Specification/CountOfSpec.php b/tests/Specification/CountOfSpec.php index 1efcdd9f..d0a92578 100644 --- a/tests/Specification/CountOfSpec.php +++ b/tests/Specification/CountOfSpec.php @@ -76,7 +76,7 @@ public function it_count_of_all_with_group_is_foo(QueryBuilder $qb): void $value = 'foo'; $context = 'user'; $parametersCount = 0; - $paramName = 'comparison_' . $parametersCount; + $paramName = 'comparison_'.$parametersCount; $this->beConstructedWith(new Equals($field, $value, $context)); From 5b5dbe65aafa575e8b066da621a653c7df977c13 Mon Sep 17 00:00:00 2001 From: Peter Gribanov Date: Tue, 28 Jul 2026 18:06:59 +0300 Subject: [PATCH 4/4] Document why treatPhpDocTypesAsCertain is disabled Records which two reports the setting silences, why the guards they point at are load-bearing rather than dead code, and that replacing the setting with a native variadic type changes InvalidArgumentException into TypeError and so needs a major release. --- phpstan.neon.dist | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index f6c9ddc0..1d8e2510 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,24 @@ parameters: level: 8 + # Silences two instanceof.alwaysTrue reports: + # + # src/Query/QueryModifierCollection.php:44 + # src/Result/ResultModifierCollection.php:43 + # + # Both collections declare their variadic as "@param QueryModifier ...$children" + # without a native type, so PHPStan trusts the PHPDoc and considers the + # instanceof guard dead code. It is not: PHPDoc is not enforced at runtime, + # these are public API classes constructed only by consumers, and the guard is + # the only thing that turns a bad argument into an InvalidArgumentException + # with a useful message. + # + # The proper fix is a native type - __construct(QueryModifier ...$children) - + # which would let PHP enforce it and make the guard genuinely removable. That + # changes the error contract from InvalidArgumentException to TypeError, so it + # has to wait for a major release. + # + # Scope: as of this commit the setting changes exactly those two reports and + # nothing else in src/. treatPhpDocTypesAsCertain: false paths: - src