diff --git a/.github/workflows/test-platforms.yml b/.github/workflows/test-platforms.yml new file mode 100644 index 00000000..aec2723d --- /dev/null +++ b/.github/workflows/test-platforms.yml @@ -0,0 +1,58 @@ +# https://help.github.com/en/categories/automating-your-workflow-with-github-actions + +name: "Test on platforms" + +on: + pull_request: + push: + branches: + - "1.3.x" + +jobs: + test-mysql: + name: "Tests mysql" + runs-on: "ubuntu-latest" + + env: + DATABASE_URL: mysql://root@127.0.0.1:3306/phpstan_doctrine + + services: + mysql: + image: mysql:${{ matrix.mysql-version }} + options: >- + --health-cmd "mysqladmin ping --silent" + --name=mysqlcontainer + -e MYSQL_ALLOW_EMPTY_PASSWORD=yes + -e MYSQL_DATABASE=phpstan_doctrine + ports: + - 3306:3306 + + strategy: + fail-fast: false + matrix: + php-version: + - "8.2" + mysql-version: + - '5.7' + - '8.0' + + steps: + - name: "Checkout" + uses: actions/checkout@v3 + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + coverage: "none" + php-version: "${{ matrix.php-version }}" + ini-file: development + extensions: "mongodb" + + - name: "Install dependencies" + run: "composer update --no-interaction --no-progress" + + - name: Change mysql sql_mode + run: "docker exec mysqlcontainer mysql -u root -e \"SET GLOBAL sql_mode = '';\"" + + - name: "Tests" + run: "vendor/bin/phpunit --group=mysql" diff --git a/Makefile b/Makefile index 2ec6452c..382dfa5c 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ check: lint cs tests phpstan .PHONY: tests tests: - php vendor/bin/phpunit + php vendor/bin/phpunit --exclude-group=platform .PHONY: lint lint: diff --git a/phpunit.xml b/phpunit.xml index 6d69639a..8c5f02c9 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -32,6 +32,7 @@ + tests tests diff --git a/tests/Type/Doctrine/Query/MysqlQueryResultTypeWalkerTest.php b/tests/Type/Doctrine/Query/MysqlQueryResultTypeWalkerTest.php new file mode 100644 index 00000000..af84e121 --- /dev/null +++ b/tests/Type/Doctrine/Query/MysqlQueryResultTypeWalkerTest.php @@ -0,0 +1,23 @@ +getMetadataFactory()->getAllMetadata(); - $schemaTool->createSchema($classes); - - $dataOne = [ - 'intColumn' => [1, 2], - 'stringColumn' => ['A', 'B'], - 'stringNullColumn' => ['A', null], - ]; - - $dataMany = [ - 'intColumn' => [1, 2], - 'stringColumn' => ['A', 'B'], - 'stringNullColumn' => ['A', null], - ]; - - $dataJoinedInheritance = [ - 'parentColumn' => [1, 2], - 'parentNullColumn' => [1, null], - 'childColumn' => [1, 2], - 'childNullColumn' => [1, null], - ]; - - $dataSingleTableInheritance = [ - 'parentColumn' => [1, 2], - 'parentNullColumn' => [1, null], - 'childNullColumn' => [1, null], - ]; - - $id = 1; - - foreach (self::combinations($dataOne) as $combination) { - [$intColumn, $stringColumn, $stringNullColumn] = $combination; - $one = new One(); - $one->id = (string) $id++; - $one->intColumn = $intColumn; - $one->stringColumn = $stringColumn; - $one->stringNullColumn = $stringNullColumn; - $embedded = new Embedded(); - $embedded->intColumn = $intColumn; - $embedded->stringColumn = $stringColumn; - $embedded->stringNullColumn = $stringNullColumn; - $nestedEmbedded = new NestedEmbedded(); - $nestedEmbedded->intColumn = $intColumn; - $nestedEmbedded->stringColumn = $stringColumn; - $nestedEmbedded->stringNullColumn = $stringNullColumn; - $embedded->nestedEmbedded = $nestedEmbedded; - $one->embedded = $embedded; - $one->manies = new ArrayCollection(); - - foreach (self::combinations($dataMany) as $combinationMany) { - [$intColumnMany, $stringColumnMany, $stringNullColumnMany] = $combinationMany; - $many = new Many(); - $many->id = (string) $id++; - $many->intColumn = $intColumnMany; - $many->stringColumn = $stringColumnMany; - $many->stringNullColumn = $stringNullColumnMany; - $many->datetimeColumn = new DateTime('2001-01-01 00:00:00'); - $many->datetimeImmutableColumn = new DateTimeImmutable('2001-01-01 00:00:00'); - $many->simpleArrayColumn = ['foo']; - $many->one = $one; - $one->manies->add($many); - $em->persist($many); - } - - $em->persist($one); - } - - foreach (self::combinations($dataJoinedInheritance) as $combination) { - [$parentColumn, $parentNullColumn, $childColumn, $childNullColumn] = $combination; - $child = new JoinedChild(); - $child->id = (string) $id++; - $child->parentColumn = $parentColumn; - $child->parentNullColumn = $parentNullColumn; - $child->childColumn = $childColumn; - $child->childNullColumn = $childNullColumn; - $em->persist($child); - } - - foreach (self::combinations($dataSingleTableInheritance) as $combination) { - [$parentColumn, $parentNullColumn, $childNullColumn] = $combination; - $child = new SingleTableChild(); - $child->id = (string) $id++; - $child->parentColumn = $parentColumn; - $child->parentNullColumn = $parentNullColumn; - $child->childNullColumn = $childNullColumn; - $em->persist($child); - } - - if (property_exists(Column::class, 'enumType') && PHP_VERSION_ID >= 80100) { - assert(class_exists(StringEnum::class)); - assert(class_exists(IntEnum::class)); - - $entityWithEnum = new EntityWithEnum(); - $entityWithEnum->id = '1'; - $entityWithEnum->stringEnumColumn = StringEnum::A; - $entityWithEnum->intEnumColumn = IntEnum::A; - $entityWithEnum->intEnumOnStringColumn = IntEnum::A; - $em->persist($entityWithEnum); - } - - $em->flush(); - } - - public static function tearDownAfterClass(): void - { - self::$em->clear(); - } - - public function setUp(): void + protected static function getEntityManagerPath(): string { - $this->descriptorRegistry = self::getContainer()->getByType(DescriptorRegistry::class); + return __DIR__ . '/../data/QueryResult/entity-manager.php'; } - /** @dataProvider getTestData */ - public function test(Type $expectedType, string $dql, ?string $expectedExceptionMessage = null, ?string $expectedDeprecationMessage = null): void - { - $em = self::$em; - - $query = $em->createQuery($dql); - - $typeBuilder = new QueryResultTypeBuilder(); - - if ($expectedExceptionMessage !== null) { - $this->expectException(Throwable::class); - $this->expectExceptionMessage($expectedExceptionMessage); - } elseif ($expectedDeprecationMessage !== null) { - $this->expectDeprecation(); - $this->expectDeprecationMessage($expectedDeprecationMessage); - } - - QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry); - - $type = $typeBuilder->getResultType(); - - self::assertSame( - $expectedType->describe(VerbosityLevel::precise()), - $type->describe(VerbosityLevel::precise()) - ); - - // Double-check our expectations - - $query = $em->createQuery($dql); - - $result = $query->getResult(); - self::assertGreaterThan(0, count($result)); - - foreach ($result as $row) { - $rowType = ConstantTypeHelper::getTypeFromValue($row); - self::assertTrue( - $type->accepts($rowType, true)->yes(), - sprintf( - "The inferred type\n%s\nshould accept actual type\n%s", - $type->describe(VerbosityLevel::precise()), - $rowType->describe(VerbosityLevel::precise()) - ) - ); - } - } - - /** - * @return iterable - */ public function getTestData(): iterable { $ormVersion = InstalledVersions::getVersion('doctrine/orm'); @@ -590,7 +389,7 @@ public function getTestData(): iterable [new ConstantIntegerType(1), new MixedType()], ]), ' - SELECT (SELECT m.intColumn FROM QueryResult\Entities\Many m) + SELECT (SELECT COUNT(m.intColumn) FROM QueryResult\Entities\Many m) FROM QueryResult\Entities\Many m2 ', ]; @@ -1591,113 +1390,4 @@ public function getTestData(): iterable ]; } - /** - * @param array $elements - */ - private function constantArray(array $elements): Type - { - $builder = ConstantArrayTypeBuilder::createEmpty(); - - foreach ($elements as $element) { - $offsetType = $element[0]; - $valueType = $element[1]; - $optional = $element[2] ?? false; - $builder->setOffsetValueType($offsetType, $valueType, $optional); - } - - return $builder->getArray(); - } - - private function numericStringOrInt(): Type - { - return new UnionType([ - new IntegerType(), - new IntersectionType([ - new StringType(), - new AccessoryNumericStringType(), - ]), - ]); - } - - private function numericString(): Type - { - return new IntersectionType([ - new StringType(), - new AccessoryNumericStringType(), - ]); - } - - private function uint(): Type - { - return IntegerRangeType::fromInterval(0, null); - } - - private function intStringified(): Type - { - return TypeCombinator::union( - new IntegerType(), - $this->numericString() - ); - } - private function uintStringified(): Type - { - return TypeCombinator::union( - $this->uint(), - $this->numericString() - ); - } - - private function numericStringified(): Type - { - return TypeCombinator::union( - new FloatType(), - new IntegerType(), - $this->numericString() - ); - } - - private function unumericStringified(): Type - { - return TypeCombinator::union( - new FloatType(), - IntegerRangeType::fromInterval(0, null), - $this->numericString() - ); - } - - private function hasTypedExpressions(): bool - { - return class_exists(TypedExpression::class); - } - - /** - * @param array $arrays - * - * @return iterable - */ - private static function combinations(array $arrays): iterable - { - if ($arrays === []) { - yield []; - return; - } - - $head = array_shift($arrays); - - foreach ($head as $elem) { - foreach (self::combinations($arrays) as $combination) { - yield array_merge([$elem], $combination); - } - } - } - - private function isDoctrine211(): bool - { - $version = InstalledVersions::getVersion('doctrine/orm'); - - return $version !== null - && version_compare($version, '2.11', '>=') - && version_compare($version, '2.12', '<'); - } - } diff --git a/tests/Type/Doctrine/Query/QueryResultTypeWalkerTestCase.php b/tests/Type/Doctrine/Query/QueryResultTypeWalkerTestCase.php new file mode 100644 index 00000000..319ac1a3 --- /dev/null +++ b/tests/Type/Doctrine/Query/QueryResultTypeWalkerTestCase.php @@ -0,0 +1,349 @@ +getMetadataFactory()->getAllMetadata(); + $schemaTool->createSchema($classes); + + $dataOne = [ + 'intColumn' => [1, 2], + 'stringColumn' => ['A', 'B'], + 'stringNullColumn' => ['A', null], + ]; + + $dataMany = [ + 'intColumn' => [1, 2], + 'stringColumn' => ['A', 'B'], + 'stringNullColumn' => ['A', null], + ]; + + $dataJoinedInheritance = [ + 'parentColumn' => [1, 2], + 'parentNullColumn' => [1, null], + 'childColumn' => [1, 2], + 'childNullColumn' => [1, null], + ]; + + $dataSingleTableInheritance = [ + 'parentColumn' => [1, 2], + 'parentNullColumn' => [1, null], + 'childNullColumn' => [1, null], + ]; + + $id = 1; + + foreach (self::combinations($dataOne) as $combination) { + [$intColumn, $stringColumn, $stringNullColumn] = $combination; + $one = new One(); + $one->id = (string) $id++; + $one->intColumn = $intColumn; + $one->stringColumn = $stringColumn; + $one->stringNullColumn = $stringNullColumn; + $embedded = new Embedded(); + $embedded->intColumn = $intColumn; + $embedded->stringColumn = $stringColumn; + $embedded->stringNullColumn = $stringNullColumn; + $nestedEmbedded = new NestedEmbedded(); + $nestedEmbedded->intColumn = $intColumn; + $nestedEmbedded->stringColumn = $stringColumn; + $nestedEmbedded->stringNullColumn = $stringNullColumn; + $embedded->nestedEmbedded = $nestedEmbedded; + $one->embedded = $embedded; + $one->manies = new ArrayCollection(); + + foreach (self::combinations($dataMany) as $combinationMany) { + [$intColumnMany, $stringColumnMany, $stringNullColumnMany] = $combinationMany; + $many = new Many(); + $many->id = (string) $id++; + $many->intColumn = $intColumnMany; + $many->stringColumn = $stringColumnMany; + $many->stringNullColumn = $stringNullColumnMany; + $many->datetimeColumn = new DateTime('2001-01-01 00:00:00'); + $many->datetimeImmutableColumn = new DateTimeImmutable('2001-01-01 00:00:00'); + $many->simpleArrayColumn = ['foo']; + $many->one = $one; + $one->manies->add($many); + $em->persist($many); + } + + $em->persist($one); + } + + foreach (self::combinations($dataJoinedInheritance) as $combination) { + [$parentColumn, $parentNullColumn, $childColumn, $childNullColumn] = $combination; + $child = new JoinedChild(); + $child->id = (string) $id++; + $child->parentColumn = $parentColumn; + $child->parentNullColumn = $parentNullColumn; + $child->childColumn = $childColumn; + $child->childNullColumn = $childNullColumn; + $em->persist($child); + } + + foreach (self::combinations($dataSingleTableInheritance) as $combination) { + [$parentColumn, $parentNullColumn, $childNullColumn] = $combination; + $child = new SingleTableChild(); + $child->id = (string) $id++; + $child->parentColumn = $parentColumn; + $child->parentNullColumn = $parentNullColumn; + $child->childNullColumn = $childNullColumn; + $em->persist($child); + } + + if (property_exists(Column::class, 'enumType') && PHP_VERSION_ID >= 80100) { + assert(class_exists(StringEnum::class)); + assert(class_exists(IntEnum::class)); + + $entityWithEnum = new EntityWithEnum(); + $entityWithEnum->id = '1'; + $entityWithEnum->stringEnumColumn = StringEnum::A; + $entityWithEnum->intEnumColumn = IntEnum::A; + $entityWithEnum->intEnumOnStringColumn = IntEnum::A; + $em->persist($entityWithEnum); + } + + $em->flush(); + } + + public static function tearDownAfterClass(): void + { + self::$em->clear(); + } + + public function setUp(): void + { + $this->descriptorRegistry = self::getContainer()->getByType(DescriptorRegistry::class); + } + + /** + * @dataProvider getTestData + */ + public function test(Type $expectedType, string $dql, ?string $expectedExceptionMessage = null, ?string $expectedDeprecationMessage = null): void + { + $em = self::$em; + + $query = $em->createQuery($dql); + + $typeBuilder = new QueryResultTypeBuilder(); + + if ($expectedExceptionMessage !== null) { + $this->expectException(Throwable::class); + $this->expectExceptionMessage($expectedExceptionMessage); + } elseif ($expectedDeprecationMessage !== null) { + $this->expectDeprecation(); + $this->expectDeprecationMessage($expectedDeprecationMessage); + } + + QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry); + + $type = $typeBuilder->getResultType(); + + self::assertSame( + $expectedType->describe(VerbosityLevel::precise()), + $type->describe(VerbosityLevel::precise()) + ); + + // Double-check our expectations + + $query = $em->createQuery($dql); + + $result = $query->getResult(); + self::assertGreaterThan(0, count($result)); + + foreach ($result as $row) { + $rowType = ConstantTypeHelper::getTypeFromValue($row); + self::assertTrue( + $type->accepts($rowType, true)->yes(), + sprintf( + "The inferred type\n%s\nshould accept actual type\n%s", + $type->describe(VerbosityLevel::precise()), + $rowType->describe(VerbosityLevel::precise()) + ) + ); + } + } + + /** + * @return iterable + */ + abstract public function getTestData(): iterable; + + /** + * @param array $elements + */ + protected function constantArray(array $elements): Type + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($elements as $element) { + $offsetType = $element[0]; + $valueType = $element[1]; + $optional = $element[2] ?? false; + $builder->setOffsetValueType($offsetType, $valueType, $optional); + } + + return $builder->getArray(); + } + + protected function numericStringOrInt(): Type + { + return new UnionType([ + new IntegerType(), + new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + ]), + ]); + } + + protected function numericString(): Type + { + return new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + ]); + } + + protected function uint(): Type + { + return IntegerRangeType::fromInterval(0, null); + } + + protected function intStringified(): Type + { + return TypeCombinator::union( + new IntegerType(), + $this->numericString() + ); + } + + protected function uintStringified(): Type + { + return TypeCombinator::union( + $this->uint(), + $this->numericString() + ); + } + + protected function numericStringified(): Type + { + return TypeCombinator::union( + new FloatType(), + new IntegerType(), + $this->numericString() + ); + } + + protected function unumericStringified(): Type + { + return TypeCombinator::union( + new FloatType(), + IntegerRangeType::fromInterval(0, null), + $this->numericString() + ); + } + + protected function hasTypedExpressions(): bool + { + return class_exists(TypedExpression::class); + } + + /** + * @param array $arrays + * + * @return iterable + */ + private static function combinations(array $arrays): iterable + { + if ($arrays === []) { + yield []; + return; + } + + $head = array_shift($arrays); + + foreach ($head as $elem) { + foreach (self::combinations($arrays) as $combination) { + yield array_merge([$elem], $combination); + } + } + } + + protected function isDoctrine211(): bool + { + $version = InstalledVersions::getVersion('doctrine/orm'); + + return $version !== null + && version_compare($version, '2.11', '>=') + && version_compare($version, '2.12', '<'); + } + +} diff --git a/tests/Type/Doctrine/data/QueryResult/config-mysql.neon b/tests/Type/Doctrine/data/QueryResult/config-mysql.neon new file mode 100644 index 00000000..588a9821 --- /dev/null +++ b/tests/Type/Doctrine/data/QueryResult/config-mysql.neon @@ -0,0 +1,7 @@ +includes: + - ../../../../../extension.neon +parameters: + doctrine: + objectManagerLoader: entity-manager-mysql.php + featureToggles: + listType: true diff --git a/tests/Type/Doctrine/data/QueryResult/entity-manager-mysql.php b/tests/Type/Doctrine/data/QueryResult/entity-manager-mysql.php new file mode 100644 index 00000000..c7887826 --- /dev/null +++ b/tests/Type/Doctrine/data/QueryResult/entity-manager-mysql.php @@ -0,0 +1,40 @@ +setProxyDir(__DIR__); +$config->setProxyNamespace('PHPstan\Doctrine\OrmProxies'); +$config->setMetadataCache(new ArrayCachePool()); + +$metadataDriver = new MappingDriverChain(); + +$metadataDriver->addDriver(new AnnotationDriver( + new AnnotationReader(), + [__DIR__ . '/Entities'] +), 'QueryResult\Entities\\'); + +if (property_exists(Column::class, 'enumType') && PHP_VERSION_ID >= 80100) { + $metadataDriver->addDriver(new AnnotationDriver( + new AnnotationReader(), + [__DIR__ . '/EntitiesEnum'] + ), 'QueryResult\EntitiesEnum\\'); +} + +$config->setMetadataDriverImpl($metadataDriver); + +return new EntityManager( + DriverManager::getConnection([ + 'driver' => 'mysql', + 'url' => 'mysql://root@127.0.0.1:3306/phpstan_doctrine', + ]), + $config +);