diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cc95dfcd..f8f49f83 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,7 +6,11 @@ on: pull_request: push: branches: - - "1.3.x" + - "2.0.x" + +concurrency: + group: build-${{ github.head_ref || github.run_id }} # will be canceled on subsequent pushes in pull requests but not branches + cancel-in-progress: true jobs: lint: @@ -17,17 +21,16 @@ jobs: fail-fast: false matrix: php-version: - - "7.2" - - "7.3" - "7.4" - "8.0" - "8.1" - "8.2" - "8.3" + - "8.4" steps: - name: "Checkout" - uses: actions/checkout@v3 + uses: actions/checkout@v5 - name: "Install PHP" uses: "shivammathur/setup-php@v2" @@ -40,10 +43,6 @@ jobs: - name: "Validate Composer" run: "composer validate" - - name: "Downgrade PHPUnit" - if: matrix.php-version == '7.2' || matrix.php-version == '7.3' - run: "composer require --dev phpunit/phpunit:^8.5.31 --no-update --update-with-dependencies" - - name: "Install dependencies" run: "composer install --no-interaction --no-progress" @@ -57,13 +56,14 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v3 + uses: actions/checkout@v5 - name: "Checkout build-cs" - uses: actions/checkout@v3 + uses: actions/checkout@v5 with: repository: "phpstan/build-cs" path: "build-cs" + ref: "2.x" - name: "Install PHP" uses: "shivammathur/setup-php@v2" @@ -97,12 +97,12 @@ jobs: fail-fast: false matrix: php-version: - - "7.2" - - "7.3" - "7.4" - "8.0" - "8.1" - "8.2" + - "8.3" + - "8.4" dependencies: - "lowest" - "highest" @@ -112,13 +112,12 @@ jobs: - php-version: "8.3" dependencies: "highest" update-packages: | - composer config extra.patches.doctrine/orm --json --merge '["compatibility/patches/Base.patch", "compatibility/patches/Column.patch", "compatibility/patches/DateAddFunction.patch", "compatibility/patches/DateSubFunction.patch", "compatibility/patches/DiscriminatorColumn.patch", "compatibility/patches/DiscriminatorMap.patch", "compatibility/patches/Embeddable.patch", "compatibility/patches/Embedded.patch", "compatibility/patches/Entity.patch", "compatibility/patches/GeneratedValue.patch", "compatibility/patches/Id.patch", "compatibility/patches/InheritanceType.patch", "compatibility/patches/JoinColumn.patch", "compatibility/patches/JoinColumns.patch", "compatibility/patches/ManyToMany.patch", "compatibility/patches/ManyToOne.patch", "compatibility/patches/MappedSuperclass.patch", "compatibility/patches/OneToMany.patch", "compatibility/patches/OneToOne.patch", "compatibility/patches/OrderBy.patch", "compatibility/patches/UniqueConstraint.patch", "compatibility/patches/Version.patch"]' - composer config extra.patches.carbonphp/carbon-doctrine-types --json --merge '["compatibility/patches/DateTimeImmutableType.patch", "compatibility/patches/DateTimeType.patch"]' + composer config extra.patches.doctrine/orm --json --merge '["compatibility/patches/Column.patch", "compatibility/patches/DiscriminatorColumn.patch", "compatibility/patches/DiscriminatorMap.patch", "compatibility/patches/Embeddable.patch", "compatibility/patches/Embedded.patch", "compatibility/patches/Entity.patch", "compatibility/patches/GeneratedValue.patch", "compatibility/patches/Id.patch", "compatibility/patches/InheritanceType.patch", "compatibility/patches/JoinColumn.patch", "compatibility/patches/JoinColumns.patch", "compatibility/patches/ManyToMany.patch", "compatibility/patches/ManyToOne.patch", "compatibility/patches/MappedSuperclass.patch", "compatibility/patches/OneToMany.patch", "compatibility/patches/OneToOne.patch", "compatibility/patches/OrderBy.patch", "compatibility/patches/UniqueConstraint.patch", "compatibility/patches/Version.patch"]' composer require --dev doctrine/orm:^3.0 doctrine/dbal:^4.0 carbonphp/carbon-doctrine-types:^3 gedmo/doctrine-extensions:^3 -W steps: - name: "Checkout" - uses: actions/checkout@v3 + uses: actions/checkout@v5 - name: "Install PHP" uses: "shivammathur/setup-php@v2" @@ -128,18 +127,10 @@ jobs: ini-file: development extensions: "mongodb" - - name: "Downgrade PHPUnit" - if: matrix.php-version == '7.2' || matrix.php-version == '7.3' - run: "composer require --dev phpunit/phpunit:^8.5.31 --no-update --update-with-dependencies" - - name: "Install lowest dependencies" if: ${{ matrix.dependencies == 'lowest' }} run: "composer update --prefer-lowest --no-interaction --no-progress" - - name: "Update Doctrine DBAl to ^3" - if: matrix.php-version != '7.2' && matrix.dependencies == 'lowest' - run: "composer require --dev doctrine/dbal:^3.3.8 --no-interaction --no-progress" - - name: "Install highest dependencies" if: ${{ matrix.dependencies == 'highest' }} run: "composer update --no-interaction --no-progress" @@ -150,6 +141,69 @@ jobs: - name: "Tests" run: "make tests" + mutation-testing: + name: "Mutation Testing" + runs-on: "ubuntu-latest" + needs: ["tests", "static-analysis"] + + strategy: + fail-fast: false + matrix: + php-version: + - "8.2" + - "8.3" + - "8.4" + + steps: + - name: "Checkout" + uses: actions/checkout@v5 + + - name: "Checkout build-infection" + uses: actions/checkout@v5 + with: + repository: "phpstan/build-infection" + path: "build-infection" + ref: "1.x" + + - uses: ./build-infection/.github/actions/setup-php + with: + php-version: "${{ matrix.php-version }}" + php-extensions: pdo, mysqli, pgsql, pdo_mysql, pdo_pgsql, pdo_sqlite, mongodb + + - name: "Install dependencies" + run: "composer install --no-interaction --no-progress" + + - name: "Install build-infection dependencies" + working-directory: "build-infection" + run: "composer install --no-interaction --no-progress" + + - name: "Configure infection" + run: | + php build-infection/bin/infection-config.php \ + > infection.json5 + cat infection.json5 | jq + + - name: "Cache Result cache" + uses: actions/cache@v4 + with: + path: ./tmp + key: "result-cache-v1-${{ matrix.php-version }}-${{ github.run_id }}" + restore-keys: | + result-cache-v1-${{ matrix.php-version }}- + + - name: "Run infection" + run: | + git fetch --depth=1 origin $GITHUB_BASE_REF + infection \ + --git-diff-base=origin/$GITHUB_BASE_REF \ + --git-diff-lines \ + --ignore-msi-with-no-mutations \ + --min-msi=100 \ + --min-covered-msi=100 \ + --log-verbosity=all \ + --debug \ + --logger-text=php://stdout + static-analysis: name: "PHPStan" runs-on: "ubuntu-latest" @@ -158,12 +212,12 @@ jobs: fail-fast: false matrix: php-version: - - "7.3" - "7.4" - "8.0" - "8.1" - "8.2" - "8.3" + - "8.4" update-packages: - "" include: @@ -172,7 +226,7 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v3 + uses: actions/checkout@v5 - name: "Install PHP" uses: "shivammathur/setup-php@v2" @@ -182,10 +236,6 @@ jobs: extensions: "mongodb" ini-file: development - - name: "Downgrade PHPUnit" - if: matrix.php-version == '7.2' || matrix.php-version == '7.3' - run: "composer require --dev phpunit/phpunit:^8.5.31 --no-update --update-with-dependencies" - - name: "Install dependencies" run: "composer update --no-interaction --no-progress" diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml index 8452d986..fd918164 100644 --- a/.github/workflows/create-tag.yml +++ b/.github/workflows/create-tag.yml @@ -21,7 +21,7 @@ jobs: runs-on: "ubuntu-latest" steps: - name: "Checkout" - uses: actions/checkout@v3 + uses: actions/checkout@v5 with: fetch-depth: 0 token: ${{ secrets.PHPSTAN_BOT_TOKEN }} diff --git a/.github/workflows/lock-closed-issues.yml b/.github/workflows/lock-closed-issues.yml index 4c7990df..78405931 100644 --- a/.github/workflows/lock-closed-issues.yml +++ b/.github/workflows/lock-closed-issues.yml @@ -2,13 +2,13 @@ name: 'Lock Issues' on: schedule: - - cron: '0 0 * * *' + - cron: '3 0 * * *' jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v4 + - uses: dessant/lock-threads@v5 with: github-token: ${{ github.token }} issue-inactive-days: '31' diff --git a/.github/workflows/platform-test.yml b/.github/workflows/platform-test.yml new file mode 100644 index 00000000..09c85f45 --- /dev/null +++ b/.github/workflows/platform-test.yml @@ -0,0 +1,80 @@ +# https://help.github.com/en/categories/automating-your-workflow-with-github-actions + +name: "Platform test" + +on: + pull_request: + push: + branches: + - "2.0.x" + +jobs: + tests: + name: "Platform test" + runs-on: "ubuntu-latest" + env: + MYSQL_HOST: '127.0.0.1' + PGSQL_HOST: '127.0.0.1' + + strategy: + fail-fast: false + matrix: + php-version: + - "7.4" + - "8.0" + - "8.1" + - "8.2" + - "8.3" + - "8.4" + update-packages: + - "" + include: + - php-version: "8.1" + update-packages: doctrine/orm:^3.0 doctrine/dbal:^4.0 carbonphp/carbon-doctrine-types:^3 gedmo/doctrine-extensions:^3 + - php-version: "8.2" + update-packages: doctrine/orm:^3.0 doctrine/dbal:^4.0 carbonphp/carbon-doctrine-types:^3 gedmo/doctrine-extensions:^3 + - php-version: "8.3" + update-packages: doctrine/orm:^3.0 doctrine/dbal:^4.0 carbonphp/carbon-doctrine-types:^3 gedmo/doctrine-extensions:^3 + - php-version: "8.4" + update-packages: doctrine/orm:^3.0 doctrine/dbal:^4.0 carbonphp/carbon-doctrine-types:^3 gedmo/doctrine-extensions:^3 + + steps: + - name: "Checkout" + uses: actions/checkout@v5 + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + coverage: "none" + php-version: "${{ matrix.php-version }}" + ini-file: development + extensions: pdo, mysqli, pgsql, pdo_mysql, pdo_pgsql, pdo_sqlite, mongodb + + - name: "Install dependencies" + run: "composer install --no-interaction --no-progress" + + - name: "Update packages" + if: ${{ matrix.update-packages != '' }} + run: composer require --dev ${{ matrix.update-packages }} -W + + - name: "Run platform matrix test" + run: vendor/bin/phpunit --group=platform + + services: + postgres: + image: "postgres:latest" + env: + POSTGRES_PASSWORD: "secret" + POSTGRES_USER: root + POSTGRES_DB: foo + ports: + - "5432:5432" + + mysql: + image: "mysql:latest" + env: + MYSQL_ALLOW_EMPTY_PASSWORD: yes + MYSQL_ROOT_PASSWORD: secret + MYSQL_DATABASE: foo + ports: + - "3306:3306" diff --git a/.github/workflows/release-toot.yml b/.github/workflows/release-toot.yml index 6a1c8156..1ba4fd77 100644 --- a/.github/workflows/release-toot.yml +++ b/.github/workflows/release-toot.yml @@ -10,7 +10,7 @@ jobs: toot: runs-on: ubuntu-latest steps: - - uses: cbrgm/mastodon-github-action@v1 + - uses: cbrgm/mastodon-github-action@v2 if: ${{ !github.event.repository.private }} with: # GitHub event payload diff --git a/.github/workflows/release-tweet.yml b/.github/workflows/release-tweet.yml index 09b39ded..d81f34ca 100644 --- a/.github/workflows/release-tweet.yml +++ b/.github/workflows/release-tweet.yml @@ -10,7 +10,7 @@ jobs: tweet: runs-on: ubuntu-latest steps: - - uses: Eomm/why-don-t-you-tweet@v1 + - uses: Eomm/why-don-t-you-tweet@v2 if: ${{ !github.event.repository.private }} with: # GitHub event payload diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 92b72547..ed7e51ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,11 +14,11 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v3 + uses: actions/checkout@v5 - name: Generate changelog id: changelog - uses: metcalfc/changelog-generator@v4.1.0 + uses: metcalfc/changelog-generator@v4.6.2 with: myToken: ${{ secrets.PHPSTAN_BOT_TOKEN }} diff --git a/.github/workflows/test-projects.yml b/.github/workflows/test-projects.yml index cb66d296..cebc3431 100644 --- a/.github/workflows/test-projects.yml +++ b/.github/workflows/test-projects.yml @@ -5,7 +5,7 @@ name: "Test projects" on: push: branches: - - "1.3.x" + - "2.0.x" jobs: test-projects: @@ -21,9 +21,9 @@ jobs: - "packagist/private-packagist" steps: - - uses: peter-evans/repository-dispatch@v2 + - uses: peter-evans/repository-dispatch@v3 with: token: ${{ secrets.REPO_ACCESS_TOKEN }} repository: "${{ matrix.repository }}" event-type: test_phpstan - client-payload: '{"ref": "1.10.x"}' + client-payload: '{"ref": "2.1.x"}' diff --git a/.gitignore b/.gitignore index 7de9f3c5..e3d740a7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /build-cs /vendor /composer.lock +/.env .phpunit.result.cache diff --git a/LICENSE b/LICENSE index 7c0f2b7b..e5f34e60 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2016 Ondřej Mirtes +Copyright (c) 2025 PHPStan s.r.o. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index 2ec6452c..8c112038 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ lint: .PHONY: cs-install cs-install: git clone https://github.com/phpstan/build-cs.git || true - git -C build-cs fetch origin && git -C build-cs reset --hard origin/main + git -C build-cs fetch origin && git -C build-cs reset --hard origin/2.x composer install --working-dir build-cs .PHONY: cs @@ -27,8 +27,8 @@ cs-fix: .PHONY: phpstan phpstan: - php vendor/bin/phpstan analyse -l 8 -c phpstan.neon src tests + php vendor/bin/phpstan analyse -c phpstan.neon .PHONY: phpstan-generate-baseline phpstan-generate-baseline: - php vendor/bin/phpstan analyse -l 8 -c phpstan.neon src tests -b phpstan-baseline.neon + php vendor/bin/phpstan analyse -c phpstan.neon -b phpstan-baseline.neon diff --git a/README.md b/README.md index 0e9a8b2d..c8a587b1 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,12 @@ $query->getResult(); // array Queries are analyzed statically and do not require a running database server. This makes use of the Doctrine DQL parser and entities metadata. -Most DQL features are supported, including `GROUP BY`, `DISTINCT`, all flavors of `JOIN`, arithmetic expressions, functions, aggregations, `NEW`, etc. Sub queries and `INDEX BY` are not yet supported (infered type will be `mixed`). +Most DQL features are supported, including `GROUP BY`, `INDEX BY`, `DISTINCT`, all flavors of `JOIN`, arithmetic expressions, functions, aggregations, `NEW`, etc. Sub queries are not yet supported (infered type will be `mixed`). + +### Query type inference of expressions + +Whether e.g. `SUM(e.column)` is fetched as `float`, `numeric-string` or `int` highly [depends on drivers, their setup and PHP version](https://github.com/janedbal/php-database-drivers-fetch-test). +This extension autodetects your setup and provides quite accurate results for `pdo_mysql`, `mysqli`, `pdo_sqlite`, `sqlite3`, `pdo_pgsql` and `pgsql`. ### Supported methods @@ -152,6 +157,20 @@ $query->getOneOrNullResult(Query::HYDRATE_OBJECT); // User This is due to the design of the `Query` class preventing from determining the hydration mode used by these functions unless it is specified explicitly during the call. +### Problematic approaches + +Not every QueryBuilder can be statically analysed, here are few advices to maximize type inferring: +- Do not pass QueryBuilder to methods +- Do not use dynamic expressions in QueryBuilder methods (mainly in `select`/`join`/`from`/`set`) + +You can enable reporting of places where inferring is unavailable by: + +```neon +parameters: + doctrine: + reportDynamicQueryBuilders: true +``` + ## Custom types If your application uses custom Doctrine types, you can write your own type descriptors to analyse them properly. @@ -181,6 +200,10 @@ Type descriptors don't have to deal with nullable types, as these are transparen If your custom type's `convertToPHPValue()` and `convertToDatabaseValue()` methods have proper typehints, you don't have to write your own descriptor for it. The `PHPStan\Type\Doctrine\Descriptors\ReflectionDescriptor` can analyse the typehints and do the rest for you. +If parent of your type is one of the Doctrine's non-abstract ones, `ReflectionDescriptor` will reuse its descriptor even for expression resolution (e.g. `AVG(t.cost)`). +For example, if you extend `Doctrine\DBAL\Types\DecimalType`, it will know that sqlite fetches that as `float|int` and other drivers as `numeric-string`. +If you extend only `Doctrine\DBAL\Types\Type`, you should use custom descriptor and optionally implement even `DoctrineTypeDriverAwareDescriptor` to provide driver-specific resolution. + ### Registering type descriptors When you write a custom type descriptor, you have to let PHPStan know about it. Add something like this into your `phpstan.neon`: @@ -196,3 +219,86 @@ services: factory: PHPStan\Type\Doctrine\Descriptors\ReflectionDescriptor('MyApp\MyCustomTypeName') tags: [phpstan.doctrine.typeDescriptor] ``` + +### Ensure types have descriptor + +If you want to be sure you never forget descriptor for some custom type, you can enable: + +```neon +parameters: + doctrine: + reportUnknownTypes: true +``` + +This causes failures when your entity uses custom type without descriptor: + +```php +#[Entity] +abstract class Uuid7Entity +{ + + #[Id] + #[Column(type: Uuid7Type::NAME)] // reported when descriptor for such type is missing + private Uuid7 $hsCode; + +``` + +## Custom DQL functions + +Any [custom DQL function](https://www.doctrine-project.org/projects/doctrine-orm/en/3.2/cookbook/dql-user-defined-functions.html) that implements Doctrine's `TypedExpression` is understood by this extension and is inferred with the type used in its `getReturnType()` method. +All other custom DQL functions are inferred as `mixed`. +Please note that you cannot use native `StringType` to cast (and infer) string results (see [ORM issue](https://github.com/doctrine/orm/issues/11537)). + +```php + +use Doctrine\DBAL\Types\Type; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Query\AST\TypedExpression; +use Doctrine\ORM\Query\AST\Functions\FunctionNode; +use Doctrine\ORM\Query\Parser; +use Doctrine\ORM\Query\SqlWalker; +use Doctrine\ORM\Query\TokenType; + +class Floor extends FunctionNode implements TypedExpression +{ + private AST\Node|string $arithmeticExpression; + + public function getSql(SqlWalker $sqlWalker): string + { + return 'FLOOR(' . $sqlWalker->walkSimpleArithmeticExpression($this->arithmeticExpression) . ')'; + } + + public function parse(Parser $parser): void + { + $parser->match(TokenType::T_IDENTIFIER); + $parser->match(TokenType::T_OPEN_PARENTHESIS); + + $this->arithmeticExpression = $parser->SimpleArithmeticExpression(); + + $parser->match(TokenType::T_CLOSE_PARENTHESIS); + } + + + public function getReturnType(): Type + { + return Type::getType(Types::INTEGER); + } +} + +``` + +## Literal strings + +Stub files in phpstan-doctrine come with many parameters marked with `literal-string`. This is a security-focused type that only allows literal strings written in code to be passed into these parameters. + +This reduces risk of SQL injection because dynamic strings from user input are not accepted in place of `literal-string`. + +An example where this type is used is `$sql` parameter in `Doctrine\Dbal\Connection::executeQuery()`. + +To enable this advanced type in phpstan-doctrine, use this configuration parameter: + +```neon +parameters: + doctrine: + literalString: true +``` diff --git a/compatibility/BackedEnum.stub b/compatibility/BackedEnum.stub new file mode 100644 index 00000000..2376a2fc --- /dev/null +++ b/compatibility/BackedEnum.stub @@ -0,0 +1,6 @@ +addMultiple($args); - } - diff --git a/compatibility/patches/DateAddFunction.patch b/compatibility/patches/DateAddFunction.patch deleted file mode 100644 index 79a7606a..00000000 --- a/compatibility/patches/DateAddFunction.patch +++ /dev/null @@ -1,10 +0,0 @@ ---- src/Query/AST/Functions/DateAddFunction.php 2024-02-09 14:22:59 -+++ src/Query/AST/Functions/DateAddFunction.php 2024-02-09 14:23:02 -@@ -71,7 +71,6 @@ - private function dispatchIntervalExpression(SqlWalker $sqlWalker): string - { - $sql = $this->intervalExpression->dispatch($sqlWalker); -- assert(is_numeric($sql)); - - return $sql; - } diff --git a/compatibility/patches/DateSubFunction.patch b/compatibility/patches/DateSubFunction.patch deleted file mode 100644 index 12a8fcf3..00000000 --- a/compatibility/patches/DateSubFunction.patch +++ /dev/null @@ -1,10 +0,0 @@ ---- src/Query/AST/Functions/DateSubFunction.php 2024-02-09 14:22:31 -+++ src/Query/AST/Functions/DateSubFunction.php 2024-02-09 14:22:50 -@@ -64,7 +64,6 @@ - private function dispatchIntervalExpression(SqlWalker $sqlWalker): string - { - $sql = $this->intervalExpression->dispatch($sqlWalker); -- assert(is_numeric($sql)); - - return $sql; - } diff --git a/compatibility/patches/DateTimeImmutableType.patch b/compatibility/patches/DateTimeImmutableType.patch deleted file mode 100644 index e8525247..00000000 --- a/compatibility/patches/DateTimeImmutableType.patch +++ /dev/null @@ -1,11 +0,0 @@ ---- src/Carbon/Doctrine/DateTimeImmutableType.php 2023-12-10 16:33:53 -+++ src/Carbon/Doctrine/DateTimeImmutableType.php 2024-02-09 11:36:50 -@@ -17,7 +17,7 @@ - /** - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - */ -- public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?DateTimeImmutable -+ public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?CarbonImmutable - { - return $this->doConvertToPHPValue($value); - } diff --git a/compatibility/patches/DateTimeType.patch b/compatibility/patches/DateTimeType.patch deleted file mode 100644 index 0a36920f..00000000 --- a/compatibility/patches/DateTimeType.patch +++ /dev/null @@ -1,11 +0,0 @@ ---- src/Carbon/Doctrine/DateTimeType.php 2023-12-10 16:33:53 -+++ src/Carbon/Doctrine/DateTimeType.php 2024-02-09 11:36:58 -@@ -17,7 +17,7 @@ - /** - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - */ -- public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?DateTime -+ public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?Carbon - { - return $this->doConvertToPHPValue($value); - } diff --git a/composer.json b/composer.json index 882b9ef9..8a54ff34 100644 --- a/composer.json +++ b/composer.json @@ -6,8 +6,8 @@ "MIT" ], "require": { - "php": "^7.2 || ^8.0", - "phpstan/phpstan": "^1.10.64" + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.13" }, "conflict": { "doctrine/collections": "<1.0", @@ -20,23 +20,24 @@ "cache/array-adapter": "^1.1", "composer/semver": "^3.3.2", "cweagans/composer-patches": "^1.7.3", - "doctrine/annotations": "^1.11 || ^2.0", + "doctrine/annotations": "^2.0", "doctrine/collections": "^1.6 || ^2.1", "doctrine/common": "^2.7 || ^3.0", - "doctrine/dbal": "^2.13.8 || ^3.3.3", + "doctrine/dbal": "^3.3.8", "doctrine/lexer": "^2.0 || ^3.0", - "doctrine/mongodb-odm": "^1.3 || ^2.4.3", + "doctrine/mongodb-odm": "^2.4.3", "doctrine/orm": "^2.16.0", "doctrine/persistence": "^2.2.1 || ^3.2", "gedmo/doctrine-extensions": "^3.8", "nesbot/carbon": "^2.49", - "nikic/php-parser": "^4.13.2", "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/phpstan-phpunit": "^1.3.13", - "phpstan/phpstan-strict-rules": "^1.5.1", - "phpunit/phpunit": "^9.6.16", + "phpstan/phpstan-deprecation-rules": "^2.0.2", + "phpstan/phpstan-phpunit": "^2.0.8", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6.20", "ramsey/uuid": "^4.2", - "symfony/cache": "^5.4" + "symfony/cache": "^5.4", + "symfony/uid": "^5.4 || ^6.4 || ^7.3" }, "config": { "sort-packages": true, diff --git a/extension.neon b/extension.neon index 01b69199..046d05d6 100644 --- a/extension.neon +++ b/extension.neon @@ -1,22 +1,15 @@ parameters: doctrine: + reportDynamicQueryBuilders: false + reportUnknownTypes: false + allowNullablePropertyForRequiredField: false repositoryClass: null ormRepositoryClass: null odmRepositoryClass: null queryBuilderClass: null allCollectionsSelectable: true objectManagerLoader: null - searchOtherMethodsForQueryBuilderBeginning: true - queryBuilderFastAlgorithm: false - featureToggles: - skipCheckGenericClasses: - - Doctrine\ODM\MongoDB\Mapping\ClassMetadata - - Doctrine\ORM\Mapping\ClassMetadata - - Doctrine\ORM\Mapping\ClassMetadataInfo - - Doctrine\Persistence\Mapping\ClassMetadata - - Doctrine\ORM\AbstractQuery - - Doctrine\ORM\Query - - Doctrine\ORM\Tools\Pagination\Paginator + literalString: false stubFiles: - stubs/Criteria.stub - stubs/DBAL/Cache/CacheException.stub @@ -30,6 +23,7 @@ parameters: - stubs/EntityManager.stub - stubs/EntityManagerDecorator.stub - stubs/EntityManagerInterface.stub + - stubs/EntityRepository.stub - stubs/MongoClassMetadataInfo.stub - stubs/Persistence/ManagerRegistry.stub @@ -38,8 +32,6 @@ parameters: - stubs/Persistence/ObjectRepository.stub - stubs/RepositoryFactory.stub - stubs/Collections/ArrayCollection.stub - - stubs/Collections/Collection.stub - - stubs/Collections/ReadableCollection.stub - stubs/Collections/Selectable.stub - stubs/ORM/AbstractQuery.stub - stubs/ORM/Exception/ORMException.stub @@ -58,6 +50,7 @@ parameters: - stubs/ORM/UnexpectedResultException.stub - stubs/ORM/Query/Expr.stub - stubs/ORM/Query.stub + - stubs/ORM/QueryBuilder.stub - stubs/ORM/Query/Expr/Comparison.stub - stubs/ORM/Query/Expr/Composite.stub - stubs/ORM/Query/Expr/Func.stub @@ -74,8 +67,10 @@ parametersSchema: queryBuilderClass: schema(string(), nullable()) allCollectionsSelectable: bool() objectManagerLoader: schema(string(), nullable()) - searchOtherMethodsForQueryBuilderBeginning: bool() - queryBuilderFastAlgorithm: bool() + reportDynamicQueryBuilders: bool() + reportUnknownTypes: bool() + allowNullablePropertyForRequiredField: bool() + literalString: bool() ]) conditionalTags: @@ -90,6 +85,14 @@ services: class: PHPStan\Type\Doctrine\DefaultDescriptorRegistry factory: @PHPStan\Type\Doctrine\DescriptorRegistryFactory::createRegistry + - + class: PHPStan\Doctrine\Driver\DriverDetector + - + class: PHPStan\Doctrine\DoctrineDiagnoseExtension + tags: + - phpstan.diagnoseExtension + - + class: PHPStan\Type\Doctrine\HydrationModeReturnTypeResolver - class: PHPStan\Reflection\Doctrine\DoctrineSelectableClassReflectionExtension - @@ -100,7 +103,6 @@ services: class: PHPStan\Type\Doctrine\QueryBuilder\CreateQueryBuilderDynamicReturnTypeExtension arguments: queryBuilderClass: %doctrine.queryBuilderClass% - fasterVersion: %doctrine.queryBuilderFastAlgorithm% tags: - phpstan.broker.dynamicMethodReturnTypeExtension - @@ -177,7 +179,6 @@ services: - class: PHPStan\Type\Doctrine\QueryBuilder\OtherMethodQueryBuilderParser arguments: - descendIntoOtherMethods: %doctrine.searchOtherMethodsForQueryBuilderBeginning% parser: @defaultAnalysisParser - @@ -189,8 +190,6 @@ services: class: PHPStan\Stubs\Doctrine\StubFilesExtensionLoader tags: - phpstan.stubFilesExtension - arguments: - bleedingEdge: %featureToggles.bleedingEdge% doctrineQueryBuilderArgumentsProcessor: class: PHPStan\Type\Doctrine\ArgumentsProcessor @@ -298,6 +297,18 @@ services: class: PHPStan\Type\Doctrine\DBAL\QueryBuilder\QueryBuilderExecuteMethodExtension tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: PHPStan\Type\Doctrine\DBAL\RowCountMethodDynamicReturnTypeExtension + arguments: + class: Doctrine\DBAL\Result + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: PHPStan\Type\Doctrine\DBAL\RowCountMethodDynamicReturnTypeExtension + arguments: + class: Doctrine\DBAL\Driver\Result + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension # Type descriptors - @@ -342,6 +353,9 @@ services: - class: PHPStan\Type\Doctrine\Descriptors\DecimalType tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\EnumType + tags: [phpstan.doctrine.typeDescriptor] - class: PHPStan\Type\Doctrine\Descriptors\FloatType tags: [phpstan.doctrine.typeDescriptor] @@ -363,6 +377,9 @@ services: - class: PHPStan\Type\Doctrine\Descriptors\SimpleArrayType tags: [phpstan.doctrine.typeDescriptor] + - + class: PHPStan\Type\Doctrine\Descriptors\SmallFloatType + tags: [phpstan.doctrine.typeDescriptor] - class: PHPStan\Type\Doctrine\Descriptors\SmallIntType tags: [phpstan.doctrine.typeDescriptor] @@ -401,6 +418,16 @@ services: tags: [phpstan.doctrine.typeDescriptor] arguments: uuidTypeName: Ramsey\Uuid\Doctrine\UuidBinaryOrderedTimeType + - + class: PHPStan\Type\Doctrine\Descriptors\Symfony\UuidTypeDescriptor + tags: [phpstan.doctrine.typeDescriptor] + arguments: + uuidTypeName: Symfony\Bridge\Doctrine\Types\UuidType + - + class: PHPStan\Type\Doctrine\Descriptors\Symfony\UlidTypeDescriptor + tags: [phpstan.doctrine.typeDescriptor] + arguments: + uuidTypeName: Symfony\Bridge\Doctrine\Types\UlidType # Doctrine Collection - @@ -427,6 +454,13 @@ services: tags: - phpstan.phpDoc.typeNodeResolverExtension + - + class: PHPStan\PhpDoc\Doctrine\DoctrineLiteralStringTypeNodeResolverExtension + arguments: + enabled: %doctrine.literalString% + tags: + - phpstan.phpDoc.typeNodeResolverExtension + - class: PHPStan\Type\Doctrine\EntityManagerInterfaceThrowTypeExtension tags: diff --git a/phpstan-baseline-dbal-4.neon b/phpstan-baseline-dbal-4.neon new file mode 100644 index 00000000..2e0880ce --- /dev/null +++ b/phpstan-baseline-dbal-4.neon @@ -0,0 +1,248 @@ +parameters: + ignoreErrors: + - + message: '#^Class Doctrine\\DBAL\\Types\\EnumType not found\.$#' + identifier: class.notFound + count: 1 + path: src/Type/Doctrine/Descriptors/EnumType.php + + - + message: '#^Method PHPStan\\Type\\Doctrine\\Descriptors\\EnumType\:\:getType\(\) should return class\-string\ but returns string\.$#' + identifier: return.type + count: 1 + path: src/Type/Doctrine/Descriptors/EnumType.php + + - + message: '#^Class Doctrine\\DBAL\\Types\\SmallFloatType not found\.$#' + identifier: class.notFound + count: 1 + path: src/Type/Doctrine/Descriptors/SmallFloatType.php + + - + message: '#^Method PHPStan\\Type\\Doctrine\\Descriptors\\SmallFloatType\:\:getType\(\) should return class\-string\ but returns string\.$#' + identifier: return.type + count: 1 + path: src/Type/Doctrine/Descriptors/SmallFloatType.php + + - + message: '#^Class Doctrine\\DBAL\\Types\\EnumType not found\.$#' + identifier: class.notFound + count: 1 + path: src/Type/Doctrine/Query/QueryResultTypeWalker.php + + - + rawMessage: ''' + Call to method __construct() of deprecated class Doctrine\ORM\Mapping\Driver\AnnotationDriver: + This class will be removed in 3.0 without replacement. + Copyright (c) Doctrine Project + From https://github.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver.php + ''' + identifier: method.deprecatedClass + count: 1 + path: tests/Classes/entity-manager.php + + - + rawMessage: ''' + Instantiation of deprecated class Doctrine\ORM\Mapping\Driver\AnnotationDriver: + This class will be removed in 3.0 without replacement. + Copyright (c) Doctrine Project + From https://github.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver.php + ''' + identifier: new.deprecatedClass + count: 1 + path: tests/Classes/entity-manager.php + + - + rawMessage: ''' + Call to method __construct() of deprecated class Doctrine\ORM\Mapping\Driver\AnnotationDriver: + This class will be removed in 3.0 without replacement. + Copyright (c) Doctrine Project + From https://github.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver.php + ''' + identifier: method.deprecatedClass + count: 1 + path: tests/DoctrineIntegration/ORM/entity-manager.php + + - + rawMessage: ''' + Instantiation of deprecated class Doctrine\ORM\Mapping\Driver\AnnotationDriver: + This class will be removed in 3.0 without replacement. + Copyright (c) Doctrine Project + From https://github.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver.php + ''' + identifier: new.deprecatedClass + count: 1 + path: tests/DoctrineIntegration/ORM/entity-manager.php + + - + rawMessage: ''' + Call to method __construct() of deprecated class Doctrine\ORM\Mapping\Driver\AnnotationDriver: + This class will be removed in 3.0 without replacement. + Copyright (c) Doctrine Project + From https://github.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver.php + ''' + identifier: method.deprecatedClass + count: 1 + path: tests/Platform/QueryResultTypeWalkerFetchTypeMatrixTest.php + + - + rawMessage: ''' + Instantiation of deprecated class Doctrine\ORM\Mapping\Driver\AnnotationDriver: + This class will be removed in 3.0 without replacement. + Copyright (c) Doctrine Project + From https://github.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver.php + ''' + identifier: new.deprecatedClass + count: 1 + path: tests/Platform/QueryResultTypeWalkerFetchTypeMatrixTest.php + + - + rawMessage: ''' + Call to method __construct() of deprecated class Doctrine\ORM\Mapping\Driver\AnnotationDriver: + This class will be removed in 3.0 without replacement. + Copyright (c) Doctrine Project + From https://github.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver.php + ''' + identifier: method.deprecatedClass + count: 1 + path: tests/Rules/DeadCode/entity-manager.php + + - + rawMessage: ''' + Instantiation of deprecated class Doctrine\ORM\Mapping\Driver\AnnotationDriver: + This class will be removed in 3.0 without replacement. + Copyright (c) Doctrine Project + From https://github.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver.php + ''' + identifier: new.deprecatedClass + count: 1 + path: tests/Rules/DeadCode/entity-manager.php + + - + rawMessage: 'Parameter #2 $type of static method Doctrine\DBAL\Types\Type::addType() expects class-string|Doctrine\DBAL\Types\Type, string given.' + identifier: argument.type + count: 1 + path: tests/Rules/Doctrine/ORM/EntityColumnRuleTest.php + + - + rawMessage: ''' + Call to method __construct() of deprecated class Doctrine\ORM\Mapping\Driver\AnnotationDriver: + This class will be removed in 3.0 without replacement. + Copyright (c) Doctrine Project + From https://github.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver.php + ''' + identifier: method.deprecatedClass + count: 1 + path: tests/Rules/Properties/entity-manager.php + + - + rawMessage: ''' + Instantiation of deprecated class Doctrine\ORM\Mapping\Driver\AnnotationDriver: + This class will be removed in 3.0 without replacement. + Copyright (c) Doctrine Project + From https://github.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver.php + ''' + identifier: new.deprecatedClass + count: 1 + path: tests/Rules/Properties/entity-manager.php + + - + rawMessage: ''' + Call to method __construct() of deprecated class Doctrine\ORM\Mapping\Driver\AnnotationDriver: + This class will be removed in 3.0 without replacement. + Copyright (c) Doctrine Project + From https://github.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver.php + ''' + identifier: method.deprecatedClass + count: 1 + path: tests/Type/Doctrine/DBAL/mysqli.php + + - + rawMessage: ''' + Instantiation of deprecated class Doctrine\ORM\Mapping\Driver\AnnotationDriver: + This class will be removed in 3.0 without replacement. + Copyright (c) Doctrine Project + From https://github.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver.php + ''' + identifier: new.deprecatedClass + count: 1 + path: tests/Type/Doctrine/DBAL/mysqli.php + + - + rawMessage: ''' + Call to method __construct() of deprecated class Doctrine\ORM\Mapping\Driver\AnnotationDriver: + This class will be removed in 3.0 without replacement. + Copyright (c) Doctrine Project + From https://github.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver.php + ''' + identifier: method.deprecatedClass + count: 1 + path: tests/Type/Doctrine/DBAL/pdo.php + + - + rawMessage: ''' + Instantiation of deprecated class Doctrine\ORM\Mapping\Driver\AnnotationDriver: + This class will be removed in 3.0 without replacement. + Copyright (c) Doctrine Project + From https://github.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver.php + ''' + identifier: new.deprecatedClass + count: 1 + path: tests/Type/Doctrine/DBAL/pdo.php + + - + rawMessage: 'Call to an undefined static method Doctrine\DBAL\Types\ConversionException::conversionFailed().' + identifier: staticMethod.notFound + count: 2 + path: tests/Rules/Doctrine/ORM/FakeTestingSymfonyUlidType.php + + - + rawMessage: 'Call to an undefined static method Doctrine\DBAL\Types\ConversionException::conversionFailedInvalidType().' + identifier: staticMethod.notFound + count: 2 + path: tests/Rules/Doctrine/ORM/FakeTestingSymfonyUlidType.php + + - + rawMessage: 'Call to an undefined static method Doctrine\DBAL\Types\ConversionException::conversionFailed().' + identifier: staticMethod.notFound + count: 2 + path: tests/Rules/Doctrine/ORM/FakeTestingSymfonyUuidType.php + + - + rawMessage: 'Call to an undefined static method Doctrine\DBAL\Types\ConversionException::conversionFailedInvalidType().' + identifier: staticMethod.notFound + count: 2 + path: tests/Rules/Doctrine/ORM/FakeTestingSymfonyUuidType.php + + - + rawMessage: ''' + Access to constant on deprecated class Doctrine\ORM\Mapping\Driver\AnnotationDriver: + This class will be removed in 3.0 without replacement. + Copyright (c) Doctrine Project + From https://github.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver.php + ''' + identifier: classConstant.deprecatedClass + count: 1 + path: src/Doctrine/Mapping/ClassMetadataFactory.php + + - + rawMessage: ''' + Call to method __construct() of deprecated class Doctrine\ORM\Mapping\Driver\AnnotationDriver: + This class will be removed in 3.0 without replacement. + Copyright (c) Doctrine Project + From https://github.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver.php + ''' + identifier: method.deprecatedClass + count: 1 + path: src/Doctrine/Mapping/ClassMetadataFactory.php + + - + rawMessage: ''' + Instantiation of deprecated class Doctrine\ORM\Mapping\Driver\AnnotationDriver: + This class will be removed in 3.0 without replacement. + Copyright (c) Doctrine Project + From https://github.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver.php + ''' + identifier: new.deprecatedClass + count: 1 + path: src/Doctrine/Mapping/ClassMetadataFactory.php diff --git a/phpstan-baseline-deprecations.neon b/phpstan-baseline-deprecations.neon new file mode 100644 index 00000000..bd96685e --- /dev/null +++ b/phpstan-baseline-deprecations.neon @@ -0,0 +1,127 @@ +parameters: + ignoreErrors: + - + message: ''' + #^Fetching class constant class of deprecated class Doctrine\\DBAL\\Types\\ArrayType\: + Use \{@link JsonType\} instead\.$# + ''' + identifier: classConstant.deprecatedClass + count: 1 + path: src/Type/Doctrine/Descriptors/ArrayType.php + + - + message: ''' + #^Fetching class constant class of deprecated class Doctrine\\DBAL\\Types\\ObjectType\: + Use \{@link JsonType\} instead\.$# + ''' + identifier: classConstant.deprecatedClass + count: 1 + path: src/Type/Doctrine/Descriptors/ObjectType.php + + - + message: ''' + #^Call to deprecated method getSchemaManager\(\) of class Doctrine\\DBAL\\Connection\: + Use \{@see createSchemaManager\(\)\} instead\.$# + ''' + identifier: method.deprecated + count: 1 + path: tests/Platform/QueryResultTypeWalkerFetchTypeMatrixTest.php + + - + message: ''' + #^Fetching class constant class of deprecated class Doctrine\\DBAL\\Types\\ArrayType\: + Use \{@link JsonType\} instead\.$# + ''' + identifier: classConstant.deprecatedClass + count: 1 + path: tests/Rules/Doctrine/ORM/EntityColumnRuleTest.php + + - + message: ''' + #^Call to deprecated method create\(\) of class Doctrine\\ORM\\EntityManager\: + Use \{@see DriverManager\:\:getConnection\(\)\} to bootstrap the connection and call the constructor\.$# + ''' + identifier: staticMethod.deprecated + count: 1 + path: src/Doctrine/Mapping/ClassMetadataFactory.php + + - + message: ''' + #^Fetching class constant class of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: classConstant.deprecatedClass + count: 1 + path: src/Doctrine/Mapping/ClassMetadataFactory.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: new.deprecated + count: 1 + path: src/Doctrine/Mapping/ClassMetadataFactory.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: new.deprecated + count: 1 + path: tests/Classes/entity-manager.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: new.deprecated + count: 1 + path: tests/DoctrineIntegration/ORM/entity-manager.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: new.deprecated + count: 1 + path: tests/Platform/QueryResultTypeWalkerFetchTypeMatrixTest.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: new.deprecated + count: 1 + path: tests/Rules/DeadCode/entity-manager.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: new.deprecated + count: 1 + path: tests/Rules/Properties/entity-manager.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: new.deprecated + count: 1 + path: tests/Type/Doctrine/DBAL/mysqli.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: new.deprecated + count: 1 + path: tests/Type/Doctrine/DBAL/pdo.php diff --git a/phpstan-baseline-orm-3.neon b/phpstan-baseline-orm-3.neon index 92e5f87c..ae5e8dfd 100644 --- a/phpstan-baseline-orm-3.neon +++ b/phpstan-baseline-orm-3.neon @@ -54,3 +54,102 @@ parameters: message: "#^Parameter \\#2 \\$className of static method Doctrine\\\\DBAL\\\\Types\\\\Type\\:\\:addType\\(\\) expects class\\-string\\, string given\\.$#" count: 1 path: tests/Rules/Doctrine/ORM/EntityColumnRuleTest.php + + - + message: ''' + #^Fetching class constant class of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\. + Copyright \(c\) Doctrine Project + From https\://github\.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver\.php$# + ''' + identifier: classConstant.deprecatedClass + count: 1 + path: src/Doctrine/Mapping/ClassMetadataFactory.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\. + Copyright \(c\) Doctrine Project + From https\://github\.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver\.php$# + ''' + identifier: new.deprecated + count: 1 + path: src/Doctrine/Mapping/ClassMetadataFactory.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\. + Copyright \(c\) Doctrine Project + From https\://github\.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver\.php$# + ''' + identifier: new.deprecated + count: 1 + path: tests/Classes/entity-manager.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\. + Copyright \(c\) Doctrine Project + From https\://github\.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver\.php$# + ''' + identifier: new.deprecated + count: 1 + path: tests/DoctrineIntegration/ORM/entity-manager.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\. + Copyright \(c\) Doctrine Project + From https\://github\.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver\.php$# + ''' + identifier: new.deprecated + count: 1 + path: tests/Platform/QueryResultTypeWalkerFetchTypeMatrixTest.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\. + Copyright \(c\) Doctrine Project + From https\://github\.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver\.php$# + ''' + identifier: new.deprecated + count: 1 + path: tests/Rules/DeadCode/entity-manager.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\. + Copyright \(c\) Doctrine Project + From https\://github\.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver\.php$# + ''' + identifier: new.deprecated + count: 1 + path: tests/Rules/Properties/entity-manager.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\. + Copyright \(c\) Doctrine Project + From https\://github\.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver\.php$# + ''' + identifier: new.deprecated + count: 1 + path: tests/Type/Doctrine/DBAL/mysqli.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\. + Copyright \(c\) Doctrine Project + From https\://github\.com/doctrine/orm/blob/40fbbf4429b0d66517244051237a2bd0616a7a13/src/Mapping/Driver/AnnotationDriver\.php$# + ''' + identifier: new.deprecated + count: 1 + path: tests/Type/Doctrine/DBAL/pdo.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index cc1e2048..cebcd4e2 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,71 +1,325 @@ parameters: ignoreErrors: - - message: "#^Calling PHPStan\\\\Type\\\\ParserNodeTypeToPHPStanType\\:\\:resolve\\(\\) is not covered by backward compatibility promise\\. The method might change in a minor PHPStan version\\.$#" + message: '#^Call to internal method Doctrine\\DBAL\\Connection\:\:getParams\(\) from outside its root namespace Doctrine\.$#' + identifier: method.internal + count: 2 + path: src/Doctrine/Driver/DriverDetector.php + + - + message: ''' + #^Access to constant on deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: classConstant.deprecatedClass count: 1 - path: src/Rules/Doctrine/ORM/EntityColumnRule.php + path: src/Doctrine/Mapping/ClassMetadataFactory.php - - message: "#^Calling PHPStan\\\\Type\\\\TypehintHelper\\:\\:decideType\\(\\) is not covered by backward compatibility promise\\. The method might change in a minor PHPStan version\\.$#" + message: ''' + #^Call to method __construct\(\) of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: method.deprecatedClass count: 1 - path: src/Rules/Doctrine/ORM/EntityColumnRule.php + path: src/Doctrine/Mapping/ClassMetadataFactory.php - - message: "#^Calling PHPStan\\\\Type\\\\ParserNodeTypeToPHPStanType\\:\\:resolve\\(\\) is not covered by backward compatibility promise\\. The method might change in a minor PHPStan version\\.$#" + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: new.deprecatedClass count: 1 - path: src/Rules/Doctrine/ORM/EntityRelationRule.php + path: src/Doctrine/Mapping/ClassMetadataFactory.php - - message: "#^Calling PHPStan\\\\Type\\\\TypehintHelper\\:\\:decideType\\(\\) is not covered by backward compatibility promise\\. The method might change in a minor PHPStan version\\.$#" + message: '#^Calling PHPStan\\Type\\TypehintHelper\:\:decideType\(\) is not covered by backward compatibility promise\. The method might change in a minor PHPStan version\.$#' + identifier: phpstanApi.method + count: 1 + path: src/Rules/Doctrine/ORM/EntityColumnRule.php + + - + message: '#^Calling PHPStan\\Type\\TypehintHelper\:\:decideType\(\) is not covered by backward compatibility promise\. The method might change in a minor PHPStan version\.$#' + identifier: phpstanApi.method count: 1 path: src/Rules/Doctrine/ORM/EntityRelationRule.php - - message: "#^PHPDoc tag @var with type class\\-string is not subtype of native type 'Doctrine\\\\\\\\Bundle…'\\.$#" + message: '#^PHPDoc tag @var with type class\-string is not subtype of native type ''Doctrine\\\\Bundle…''\.$#' + identifier: varTag.nativeType count: 1 path: src/Stubs/Doctrine/StubFilesExtensionLoader.php - - message: "#^Accessing PHPStan\\\\Rules\\\\Classes\\\\InstantiationRule\\:\\:class is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#" + message: ''' + #^Catching deprecated class Doctrine\\Common\\CommonException\: + The doctrine/common package is deprecated, please use specific packages and their exceptions instead\.$# + ''' + identifier: catch.deprecatedClass + count: 1 + path: src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php + + - + message: ''' + #^Catching deprecated class Doctrine\\ORM\\ORMException\: + Use Doctrine\\ORM\\Exception\\ORMException for catch and instanceof$# + ''' + identifier: catch.deprecatedClass + count: 1 + path: src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php + + - + message: ''' + #^Access to constant on deprecated class Doctrine\\DBAL\\Types\\ArrayType\: + Use \{@link JsonType\} instead\.$# + ''' + identifier: classConstant.deprecatedClass + count: 1 + path: src/Type/Doctrine/Descriptors/ArrayType.php + + - + message: ''' + #^Access to constant on deprecated class Doctrine\\DBAL\\Types\\ObjectType\: + Use \{@link JsonType\} instead\.$# + ''' + identifier: classConstant.deprecatedClass + count: 1 + path: src/Type/Doctrine/Descriptors/ObjectType.php + + - + message: '#^Parameter \$condExpr of method PHPStan\\Type\\Doctrine\\Query\\QueryResultTypeWalker\:\:walkConditionalExpression\(\) has typehint with internal interface Doctrine\\ORM\\Query\\AST\\Phase2OptimizableConditional\.$#' + identifier: parameter.internalInterface + count: 1 + path: src/Type/Doctrine/Query/QueryResultTypeWalker.php + + - + message: '#^Parameter \$condExpr of method PHPStan\\Type\\Doctrine\\Query\\QueryResultTypeWalker\:\:walkJoinAssociationDeclaration\(\) has typehint with internal interface Doctrine\\ORM\\Query\\AST\\Phase2OptimizableConditional\.$#' + identifier: parameter.internalInterface + count: 1 + path: src/Type/Doctrine/Query/QueryResultTypeWalker.php + + - + message: ''' + #^Catching deprecated class Doctrine\\Common\\CommonException\: + The doctrine/common package is deprecated, please use specific packages and their exceptions instead\.$# + ''' + identifier: catch.deprecatedClass + count: 1 + path: src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php + + - + message: ''' + #^Catching deprecated class Doctrine\\ORM\\ORMException\: + Use Doctrine\\ORM\\Exception\\ORMException for catch and instanceof$# + ''' + identifier: catch.deprecatedClass + count: 1 + path: src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php + + - + message: '#^Accessing PHPStan\\Rules\\Classes\\InstantiationRule\:\:class is not covered by backward compatibility promise\. The class might change in a minor PHPStan version\.$#' + identifier: phpstanApi.classConstant count: 1 path: tests/Classes/DoctrineProxyForbiddenClassNamesExtensionTest.php - - message: "#^Accessing PHPStan\\\\Rules\\\\DeadCode\\\\UnusedPrivatePropertyRule\\:\\:class is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#" + message: ''' + #^Call to method __construct\(\) of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: method.deprecatedClass + count: 1 + path: tests/Classes/entity-manager.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: new.deprecatedClass + count: 1 + path: tests/Classes/entity-manager.php + + - + message: ''' + #^Call to method __construct\(\) of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: method.deprecatedClass + count: 1 + path: tests/DoctrineIntegration/ORM/entity-manager.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: new.deprecatedClass + count: 1 + path: tests/DoctrineIntegration/ORM/entity-manager.php + + - + message: '#^Call to internal method PHPUnit\\Framework\\TestCase\:\:dataName\(\) from outside its root namespace PHPUnit\.$#' + identifier: method.internal + count: 14 + path: tests/Platform/QueryResultTypeWalkerFetchTypeMatrixTest.php + + - + message: ''' + #^Call to method __construct\(\) of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: method.deprecatedClass + count: 1 + path: tests/Platform/QueryResultTypeWalkerFetchTypeMatrixTest.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: new.deprecatedClass + count: 1 + path: tests/Platform/QueryResultTypeWalkerFetchTypeMatrixTest.php + + - + message: '#^Call to internal method Doctrine\\DBAL\\Driver\\PDO\\Connection\:\:__construct\(\) from outside its root namespace Doctrine\.$#' + identifier: method.internal + count: 1 + path: tests/Platform/UnknownDriver.php + + - + message: '#^Accessing PHPStan\\Rules\\DeadCode\\UnusedPrivatePropertyRule\:\:class is not covered by backward compatibility promise\. The class might change in a minor PHPStan version\.$#' + identifier: phpstanApi.classConstant count: 1 path: tests/Rules/DeadCode/UnusedPrivatePropertyRuleTest.php - - message: "#^Accessing PHPStan\\\\Rules\\\\Methods\\\\CallMethodsRule\\:\\:class is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#" + message: ''' + #^Call to method __construct\(\) of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: method.deprecatedClass + count: 1 + path: tests/Rules/DeadCode/entity-manager.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: new.deprecatedClass + count: 1 + path: tests/Rules/DeadCode/entity-manager.php + + - + message: ''' + #^Access to constant on deprecated class Doctrine\\DBAL\\Types\\ArrayType\: + Use \{@link JsonType\} instead\.$# + ''' + identifier: classConstant.deprecatedClass + count: 1 + path: tests/Rules/Doctrine/ORM/EntityColumnRuleTest.php + + - + message: '#^Accessing PHPStan\\Rules\\Methods\\CallMethodsRule\:\:class is not covered by backward compatibility promise\. The class might change in a minor PHPStan version\.$#' + identifier: phpstanApi.classConstant count: 1 path: tests/Rules/Doctrine/ORM/MagicRepositoryMethodCallRuleTest.php - - message: "#^Accessing PHPStan\\\\Rules\\\\Exceptions\\\\CatchWithUnthrownExceptionRule\\:\\:class is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#" + message: '#^Accessing PHPStan\\Rules\\Exceptions\\CatchWithUnthrownExceptionRule\:\:class is not covered by backward compatibility promise\. The class might change in a minor PHPStan version\.$#' + identifier: phpstanApi.classConstant count: 1 path: tests/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php - - message: "#^Accessing PHPStan\\\\Rules\\\\Exceptions\\\\TooWideMethodThrowTypeRule\\:\\:class is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#" + message: '#^Accessing PHPStan\\Rules\\Exceptions\\TooWideMethodThrowTypeRule\:\:class is not covered by backward compatibility promise\. The class might change in a minor PHPStan version\.$#' + identifier: phpstanApi.classConstant count: 1 path: tests/Rules/Exceptions/TooWideMethodThrowTypeRuleTest.php - - message: "#^Accessing PHPStan\\\\Rules\\\\DeadCode\\\\UnusedPrivatePropertyRule\\:\\:class is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#" + message: '#^Accessing PHPStan\\Rules\\DeadCode\\UnusedPrivatePropertyRule\:\:class is not covered by backward compatibility promise\. The class might change in a minor PHPStan version\.$#' + identifier: phpstanApi.classConstant count: 1 path: tests/Rules/Properties/MissingGedmoByPhpDocPropertyAssignRuleTest.php - - message: "#^Accessing PHPStan\\\\Rules\\\\DeadCode\\\\UnusedPrivatePropertyRule\\:\\:class is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#" + message: '#^Accessing PHPStan\\Rules\\DeadCode\\UnusedPrivatePropertyRule\:\:class is not covered by backward compatibility promise\. The class might change in a minor PHPStan version\.$#' + identifier: phpstanApi.classConstant count: 1 path: tests/Rules/Properties/MissingGedmoPropertyAssignRuleTest.php - - message: "#^Accessing PHPStan\\\\Rules\\\\Properties\\\\MissingReadOnlyByPhpDocPropertyAssignRule\\:\\:class is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#" + message: '#^Accessing PHPStan\\Rules\\Properties\\MissingReadOnlyByPhpDocPropertyAssignRule\:\:class is not covered by backward compatibility promise\. The class might change in a minor PHPStan version\.$#' + identifier: phpstanApi.classConstant count: 1 path: tests/Rules/Properties/MissingReadOnlyByPhpDocPropertyAssignRuleTest.php - - message: "#^Accessing PHPStan\\\\Rules\\\\Properties\\\\MissingReadOnlyPropertyAssignRule\\:\\:class is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#" + message: '#^Accessing PHPStan\\Rules\\Properties\\MissingReadOnlyPropertyAssignRule\:\:class is not covered by backward compatibility promise\. The class might change in a minor PHPStan version\.$#' + identifier: phpstanApi.classConstant count: 1 path: tests/Rules/Properties/MissingReadOnlyPropertyAssignRuleTest.php + + - + message: ''' + #^Call to method __construct\(\) of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: method.deprecatedClass + count: 1 + path: tests/Rules/Properties/entity-manager.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: new.deprecatedClass + count: 1 + path: tests/Rules/Properties/entity-manager.php + + - + message: ''' + #^Call to method __construct\(\) of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: method.deprecatedClass + count: 1 + path: tests/Type/Doctrine/DBAL/mysqli.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: new.deprecatedClass + count: 1 + path: tests/Type/Doctrine/DBAL/mysqli.php + + - + message: ''' + #^Call to method __construct\(\) of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: method.deprecatedClass + count: 1 + path: tests/Type/Doctrine/DBAL/pdo.php + + - + message: ''' + #^Instantiation of deprecated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver\: + This class will be removed in 3\.0 without replacement\.$# + ''' + identifier: new.deprecatedClass + count: 1 + path: tests/Type/Doctrine/DBAL/pdo.php + + - + message: '#^Parameter references internal interface Doctrine\\ORM\\Query\\AST\\Phase2OptimizableConditional in its type\.$#' + identifier: parameter.internalInterface + count: 2 + path: src/Type/Doctrine/Query/QueryResultTypeWalker.php diff --git a/phpstan.neon b/phpstan.neon index b3c5d642..b5f35ecc 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,14 +2,24 @@ includes: - extension.neon - rules.neon - phpstan-baseline.neon + - phpstan-baseline-deprecations.neon - phpstan-baseline-dbal-3.neon + - phpstan-baseline-dbal-4.neon - compatibility/orm-3-baseline.php - vendor/phpstan/phpstan-strict-rules/rules.neon + - vendor/phpstan/phpstan-deprecation-rules/rules.neon - vendor/phpstan/phpstan-phpunit/extension.neon - vendor/phpstan/phpstan-phpunit/rules.neon - phar://phpstan.phar/conf/bleedingEdge.neon parameters: + level: 8 + paths: + - src + - tests + + resultCachePath: tmp/resultCache.php + excludePaths: - tests/*/data/* - tests/*/data-attributes/* @@ -42,4 +52,34 @@ parameters: - message: '#^Call to function method_exists\(\) with ''Doctrine\\\\ORM\\\\EntityManager'' and ''create'' will always evaluate to true\.$#' path: src/Doctrine/Mapping/ClassMetadataFactory.php - reportUnmatched: false + - + message: '#^Call to function ini_set\(\) with deprecated option ''assert\.exception''\.$#' + path: tests/bootstrap.php + + - + message: '#^Call to function method_exists\(\) with Doctrine\\DBAL\\Connection and ''getNativeConnection'' will always evaluate to true\.$#' # needed for older DBAL versions + paths: + - src/Type/Doctrine/Query/QueryResultTypeWalker.php + - src/Doctrine/Driver/DriverDetector.php + + - + messages: # needed for older DBAL versions + - '#^Class PgSql\\Connection not found\.$#' + - '#^Class Doctrine\\DBAL\\Driver\\PgSQL\\Driver not found\.$#' + - '#^Class Doctrine\\DBAL\\Driver\\SQLite3\\Driver not found\.$#' + + - + message: '#^Call to an undefined method Doctrine\\DBAL\\Connection\:\:getWrappedConnection\(\)\.$#' # dropped in DBAL 4 + path: src/Type/Doctrine/Query/QueryResultTypeWalker.php + + - + messages: # oldest dbal has only getSchemaManager, dbal4 has only createSchemaManager + - '#^Call to function method_exists\(\) with Doctrine\\DBAL\\Connection and ''createSchemaManager'' will always evaluate to true\.$#' + - '#^Call to an undefined method Doctrine\\DBAL\\Connection\:\:getSchemaManager\(\)\.$#' + path: tests/Platform/QueryResultTypeWalkerFetchTypeMatrixTest.php + +services: + - + class: PHPStan\BackedEnumStubExtension + tags: + - phpstan.stubFilesExtension diff --git a/phpunit.xml b/phpunit.xml index 6d69639a..02222436 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -11,11 +11,9 @@ failOnRisky="true" failOnWarning="true" xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" - convertDeprecationsToExceptions="true" + convertDeprecationsToExceptions="true" + executionOrder="random" > - - - ./src @@ -36,5 +34,11 @@ + + + platform + + + diff --git a/rules.neon b/rules.neon index feb41184..0853184d 100644 --- a/rules.neon +++ b/rules.neon @@ -12,24 +12,19 @@ parametersSchema: queryBuilderClass: schema(string(), nullable()) allCollectionsSelectable: bool() objectManagerLoader: schema(string(), nullable()) - searchOtherMethodsForQueryBuilderBeginning: bool() - queryBuilderFastAlgorithm: bool() reportDynamicQueryBuilders: bool() reportUnknownTypes: bool() allowNullablePropertyForRequiredField: bool() + literalString: bool() ]) rules: - PHPStan\Rules\Doctrine\ORM\DqlRule - PHPStan\Rules\Doctrine\ORM\RepositoryMethodCallRule + - PHPStan\Rules\Doctrine\ORM\EntityConstructorNotFinalRule + - PHPStan\Rules\Doctrine\ORM\EntityMappingExceptionRule - PHPStan\Rules\Doctrine\ORM\EntityNotFinalRule -conditionalTags: - PHPStan\Rules\Doctrine\ORM\EntityMappingExceptionRule: - phpstan.rules.rule: %featureToggles.bleedingEdge% - PHPStan\Rules\Doctrine\ORM\EntityConstructorNotFinalRule: - phpstan.rules.rule: %featureToggles.bleedingEdge% - services: - class: PHPStan\Rules\Doctrine\ORM\QueryBuilderDqlRule @@ -42,23 +37,15 @@ services: arguments: reportUnknownTypes: %doctrine.reportUnknownTypes% allowNullablePropertyForRequiredField: %doctrine.allowNullablePropertyForRequiredField% - bleedingEdge: %featureToggles.bleedingEdge% descriptorRegistry: @doctrineTypeDescriptorRegistry tags: - phpstan.rules.rule - - - class: PHPStan\Rules\Doctrine\ORM\EntityMappingExceptionRule - - - class: PHPStan\Rules\Doctrine\ORM\EntityNotFinalRule - class: PHPStan\Rules\Doctrine\ORM\EntityRelationRule arguments: allowNullablePropertyForRequiredField: %doctrine.allowNullablePropertyForRequiredField% - bleedingEdge: %featureToggles.bleedingEdge% tags: - phpstan.rules.rule - - - class: PHPStan\Rules\Doctrine\ORM\EntityConstructorNotFinalRule - class: PHPStan\Classes\DoctrineProxyForbiddenClassNamesExtension tags: diff --git a/src/Classes/DoctrineProxyForbiddenClassNamesExtension.php b/src/Classes/DoctrineProxyForbiddenClassNamesExtension.php index 1ff7f4f6..dbca26af 100644 --- a/src/Classes/DoctrineProxyForbiddenClassNamesExtension.php +++ b/src/Classes/DoctrineProxyForbiddenClassNamesExtension.php @@ -8,8 +8,7 @@ class DoctrineProxyForbiddenClassNamesExtension implements ForbiddenClassNameExtension { - /** @var ObjectMetadataResolver */ - private $objectMetadataResolver; + private ObjectMetadataResolver $objectMetadataResolver; public function __construct(ObjectMetadataResolver $objectMetadataResolver) { diff --git a/src/Doctrine/DoctrineDiagnoseExtension.php b/src/Doctrine/DoctrineDiagnoseExtension.php new file mode 100644 index 00000000..8973fc7c --- /dev/null +++ b/src/Doctrine/DoctrineDiagnoseExtension.php @@ -0,0 +1,81 @@ +objectMetadataResolver = $objectMetadataResolver; + $this->driverDetector = $driverDetector; + } + + public function print(Output $output): void + { + $output->writeLineFormatted(sprintf( + 'Doctrine\'s objectManagerLoader: %s', + $this->objectMetadataResolver->hasObjectManagerLoader() ? 'In use' : 'No', + )); + + $objectManager = $this->objectMetadataResolver->getObjectManager(); + if ($objectManager instanceof EntityManagerInterface) { + $connection = $objectManager->getConnection(); + $driver = $this->driverDetector->detect($connection); + + $output->writeLineFormatted(sprintf( + 'Detected driver: %s', + $driver ?? 'None', + )); + } + + $packages = []; + $candidates = [ + 'doctrine/dbal', + 'doctrine/orm', + 'doctrine/common', + 'doctrine/collections', + 'doctrine/persistence', + ]; + foreach ($candidates as $package) { + try { + $installedVersion = InstalledVersions::getPrettyVersion($package); + } catch (OutOfBoundsException $e) { + continue; + } + + if ($installedVersion === null) { + continue; + } + + $packages[$package] = $installedVersion; + } + + if (count($packages) > 0) { + $output->writeLineFormatted('Installed Doctrine packages:'); + foreach ($packages as $package => $version) { + $output->writeLineFormatted(sprintf('%s: %s', $package, $version)); + } + } + + $output->writeLineFormatted(''); + } + +} diff --git a/src/Doctrine/Driver/DriverDetector.php b/src/Doctrine/Driver/DriverDetector.php new file mode 100644 index 00000000..0c9e5df0 --- /dev/null +++ b/src/Doctrine/Driver/DriverDetector.php @@ -0,0 +1,148 @@ +getDriver(); + + return $this->deduceFromDriverClass(get_class($driver)) ?? $this->deduceFromParams($connection); + } + + /** + * @return array + */ + public function detectDriverOptions(Connection $connection): array + { + return $connection->getParams()['driverOptions'] ?? []; + } + + /** + * @return self::*|null + */ + private function deduceFromDriverClass(string $driverClass): ?string + { + if (is_a($driverClass, MysqliDriver::class, true)) { + return self::MYSQLI; + } + + if (is_a($driverClass, PdoMysqlDriver::class, true)) { + return self::PDO_MYSQL; + } + + if (is_a($driverClass, PdoSQLiteDriver::class, true)) { + return self::PDO_SQLITE; + } + + if (is_a($driverClass, PdoSqlSrvDriver::class, true)) { + return self::PDO_SQLSRV; + } + + if (is_a($driverClass, PdoOciDriver::class, true)) { + return self::PDO_OCI; + } + + if (is_a($driverClass, PdoPgSQLDriver::class, true)) { + return self::PDO_PGSQL; + } + + if (is_a($driverClass, SQLite3Driver::class, true)) { + return self::SQLITE3; + } + + if (is_a($driverClass, PgSQLDriver::class, true)) { + return self::PGSQL; + } + + if (is_a($driverClass, SqlSrvDriver::class, true)) { + return self::SQLSRV; + } + + if (is_a($driverClass, Oci8Driver::class, true)) { + return self::OCI8; + } + + if (is_a($driverClass, IbmDb2Driver::class, true)) { + return self::IBM_DB2; + } + + return null; + } + + /** + * @return self::*|null + */ + private function deduceFromParams(Connection $connection): ?string + { + $params = $connection->getParams(); + + if (isset($params['driver'])) { + switch ($params['driver']) { + case 'pdo_mysql': + return self::PDO_MYSQL; + case 'pdo_sqlite': + return self::PDO_SQLITE; + case 'pdo_pgsql': + return self::PDO_PGSQL; + case 'pdo_oci': + return self::PDO_OCI; + case 'oci8': + return self::OCI8; + case 'ibm_db2': + return self::IBM_DB2; + case 'pdo_sqlsrv': + return self::PDO_SQLSRV; + case 'mysqli': + return self::MYSQLI; + case 'pgsql': // @phpstan-ignore-line never matches on PHP 7.3- with old dbal + return self::PGSQL; + case 'sqlsrv': + return self::SQLSRV; + case 'sqlite3': // @phpstan-ignore-line never matches on PHP 7.3- with old dbal + return self::SQLITE3; + default: + return null; + } + } + + if (isset($params['driverClass'])) { + return $this->deduceFromDriverClass($params['driverClass']); + } + + return null; + } + +} diff --git a/src/Doctrine/Mapping/ClassMetadataFactory.php b/src/Doctrine/Mapping/ClassMetadataFactory.php index b2f82388..96dec3bd 100644 --- a/src/Doctrine/Mapping/ClassMetadataFactory.php +++ b/src/Doctrine/Mapping/ClassMetadataFactory.php @@ -18,8 +18,7 @@ class ClassMetadataFactory extends \Doctrine\ORM\Mapping\ClassMetadataFactory { - /** @var string */ - private $tmpDir; + private string $tmpDir; public function __construct(string $tmpDir) { @@ -40,9 +39,16 @@ protected function initialize(): void $config = new Configuration(); $config->setMetadataDriverImpl(count($drivers) === 1 ? $drivers[0] : new MappingDriverChain($drivers)); - $config->setAutoGenerateProxyClasses(true); - $config->setProxyDir($this->tmpDir); - $config->setProxyNamespace('__PHPStanDoctrine__\\Proxy'); + + // @phpstan-ignore function.impossibleType (Available since Doctrine ORM 3.4) + if (PHP_VERSION_ID >= 80400 && method_exists($config, 'enableNativeLazyObjects')) { + $config->enableNativeLazyObjects(true); + } else { + $config->setAutoGenerateProxyClasses(true); + $config->setProxyDir($this->tmpDir); + $config->setProxyNamespace('__PHPStanDoctrine__\\Proxy'); + } + $connection = DriverManager::getConnection([ 'driver' => 'pdo_sqlite', 'memory' => true, diff --git a/src/Doctrine/Mapping/MappingDriverChain.php b/src/Doctrine/Mapping/MappingDriverChain.php index ebcdc423..532b208b 100644 --- a/src/Doctrine/Mapping/MappingDriverChain.php +++ b/src/Doctrine/Mapping/MappingDriverChain.php @@ -11,7 +11,7 @@ class MappingDriverChain implements MappingDriver { /** @var MappingDriver[] */ - private $drivers; + private array $drivers; /** * @param MappingDriver[] $drivers diff --git a/src/PhpDoc/Doctrine/DoctrineLiteralStringTypeNodeResolverExtension.php b/src/PhpDoc/Doctrine/DoctrineLiteralStringTypeNodeResolverExtension.php new file mode 100644 index 00000000..b034f57a --- /dev/null +++ b/src/PhpDoc/Doctrine/DoctrineLiteralStringTypeNodeResolverExtension.php @@ -0,0 +1,44 @@ +enabled = $enabled; + } + + public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type + { + if (!$typeNode instanceof IdentifierTypeNode) { + return null; + } + + if ($typeNode->name !== '__doctrine-literal-string') { + return null; + } + + if ($this->enabled) { + return new IntersectionType([ + new StringType(), + new AccessoryLiteralStringType(), + ]); + } + + return new StringType(); + } + +} diff --git a/src/PhpDoc/Doctrine/QueryTypeNodeResolverExtension.php b/src/PhpDoc/Doctrine/QueryTypeNodeResolverExtension.php index 39cd65d9..c8193892 100644 --- a/src/PhpDoc/Doctrine/QueryTypeNodeResolverExtension.php +++ b/src/PhpDoc/Doctrine/QueryTypeNodeResolverExtension.php @@ -18,8 +18,7 @@ class QueryTypeNodeResolverExtension implements TypeNodeResolverExtension, TypeNodeResolverAwareExtension { - /** @var TypeNodeResolver */ - private $typeNodeResolver; + private TypeNodeResolver $typeNodeResolver; public function setTypeNodeResolver(TypeNodeResolver $typeNodeResolver): void { @@ -47,7 +46,7 @@ public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type [ new NullType(), $this->typeNodeResolver->resolve($typeNode->genericTypes[0], $nameScope), - ] + ], ); } diff --git a/src/Reflection/Doctrine/DoctrineSelectableClassReflectionExtension.php b/src/Reflection/Doctrine/DoctrineSelectableClassReflectionExtension.php index 16970a60..ca726d8c 100644 --- a/src/Reflection/Doctrine/DoctrineSelectableClassReflectionExtension.php +++ b/src/Reflection/Doctrine/DoctrineSelectableClassReflectionExtension.php @@ -10,8 +10,7 @@ class DoctrineSelectableClassReflectionExtension implements MethodsClassReflectionExtension { - /** @var ReflectionProvider */ - private $reflectionProvider; + private ReflectionProvider $reflectionProvider; public function __construct(ReflectionProvider $reflectionProvider) { diff --git a/src/Reflection/Doctrine/DummyParameter.php b/src/Reflection/Doctrine/DummyParameter.php index 725f29f6..00eefa2c 100644 --- a/src/Reflection/Doctrine/DummyParameter.php +++ b/src/Reflection/Doctrine/DummyParameter.php @@ -9,23 +9,17 @@ class DummyParameter implements ParameterReflection { - /** @var string */ - private $name; + private string $name; - /** @var Type */ - private $type; + private Type $type; - /** @var bool */ - private $optional; + private bool $optional; - /** @var PassedByReference */ - private $passedByReference; + private PassedByReference $passedByReference; - /** @var bool */ - private $variadic; + private bool $variadic; - /** @var Type|null */ - private $defaultValue; + private ?Type $defaultValue = null; public function __construct(string $name, Type $type, bool $optional, ?PassedByReference $passedByReference, bool $variadic, ?Type $defaultValue) { diff --git a/src/Reflection/Doctrine/EntityRepositoryClassReflectionExtension.php b/src/Reflection/Doctrine/EntityRepositoryClassReflectionExtension.php index 799349da..52a8dcff 100644 --- a/src/Reflection/Doctrine/EntityRepositoryClassReflectionExtension.php +++ b/src/Reflection/Doctrine/EntityRepositoryClassReflectionExtension.php @@ -22,8 +22,7 @@ class EntityRepositoryClassReflectionExtension implements MethodsClassReflectionExtension { - /** @var ObjectMetadataResolver */ - private $objectMetadataResolver; + private ObjectMetadataResolver $objectMetadataResolver; public function __construct(ObjectMetadataResolver $objectMetadataResolver) { diff --git a/src/Reflection/Doctrine/MagicRepositoryMethodReflection.php b/src/Reflection/Doctrine/MagicRepositoryMethodReflection.php index 125f1440..94dc6011 100644 --- a/src/Reflection/Doctrine/MagicRepositoryMethodReflection.php +++ b/src/Reflection/Doctrine/MagicRepositoryMethodReflection.php @@ -21,14 +21,11 @@ class MagicRepositoryMethodReflection implements MethodReflection { - /** @var ClassReflection */ - private $declaringClass; + private ClassReflection $declaringClass; - /** @var string */ - private $name; + private string $name; - /** @var Type */ - private $type; + private Type $type; public function __construct( ClassReflection $declaringClass, @@ -104,7 +101,7 @@ public function getVariants(): array null, $arguments, false, - $this->type + $this->type, ), ]; } diff --git a/src/Rules/Doctrine/ORM/DqlRule.php b/src/Rules/Doctrine/ORM/DqlRule.php index 7a121525..23f4da41 100644 --- a/src/Rules/Doctrine/ORM/DqlRule.php +++ b/src/Rules/Doctrine/ORM/DqlRule.php @@ -11,7 +11,6 @@ use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Type\Doctrine\ObjectMetadataResolver; use PHPStan\Type\ObjectType; -use PHPStan\Type\TypeUtils; use function count; use function sprintf; @@ -21,8 +20,7 @@ class DqlRule implements Rule { - /** @var ObjectMetadataResolver */ - private $objectMetadataResolver; + private ObjectMetadataResolver $objectMetadataResolver; public function __construct(ObjectMetadataResolver $objectMetadataResolver) { @@ -55,7 +53,7 @@ public function processNode(Node $node, Scope $scope): array return []; } - $dqls = TypeUtils::getConstantStrings($scope->getType($node->getArgs()[0]->value)); + $dqls = $scope->getType($node->getArgs()[0]->value)->getConstantStrings(); if (count($dqls) === 0) { return []; } diff --git a/src/Rules/Doctrine/ORM/EntityColumnRule.php b/src/Rules/Doctrine/ORM/EntityColumnRule.php index e51e9c95..75aa1ae6 100644 --- a/src/Rules/Doctrine/ORM/EntityColumnRule.php +++ b/src/Rules/Doctrine/ORM/EntityColumnRule.php @@ -9,6 +9,7 @@ use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Type\ArrayType; +use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\Doctrine\DescriptorNotRegisteredException; use PHPStan\Type\Doctrine\DescriptorRegistry; use PHPStan\Type\Doctrine\ObjectMetadataResolver; @@ -16,15 +17,18 @@ use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; -use PHPStan\Type\ParserNodeTypeToPHPStanType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypehintHelper; use PHPStan\Type\TypeTraverser; +use PHPStan\Type\TypeUtils; use PHPStan\Type\VerbosityLevel; use Throwable; +use function count; use function get_class; use function in_array; +use function is_array; +use function is_string; use function sprintf; /** @@ -33,31 +37,22 @@ class EntityColumnRule implements Rule { - /** @var ObjectMetadataResolver */ - private $objectMetadataResolver; + private ObjectMetadataResolver $objectMetadataResolver; - /** @var DescriptorRegistry */ - private $descriptorRegistry; + private DescriptorRegistry $descriptorRegistry; - /** @var ReflectionProvider */ - private $reflectionProvider; + private ReflectionProvider $reflectionProvider; - /** @var bool */ - private $reportUnknownTypes; + private bool $reportUnknownTypes; - /** @var bool */ - private $allowNullablePropertyForRequiredField; - - /** @var bool */ - private $bleedingEdge; + private bool $allowNullablePropertyForRequiredField; public function __construct( ObjectMetadataResolver $objectMetadataResolver, DescriptorRegistry $descriptorRegistry, ReflectionProvider $reflectionProvider, bool $reportUnknownTypes, - bool $allowNullablePropertyForRequiredField, - bool $bleedingEdge + bool $allowNullablePropertyForRequiredField ) { $this->objectMetadataResolver = $objectMetadataResolver; @@ -65,7 +60,6 @@ public function __construct( $this->reflectionProvider = $reflectionProvider; $this->reportUnknownTypes = $reportUnknownTypes; $this->allowNullablePropertyForRequiredField = $allowNullablePropertyForRequiredField; - $this->bleedingEdge = $bleedingEdge; } public function getNodeType(): string @@ -75,9 +69,6 @@ public function getNodeType(): string public function processNode(Node $node, Scope $scope): array { - if (!$this->bleedingEdge && !$this->objectMetadataResolver->hasObjectManagerLoader()) { - return []; - } $class = $scope->getClassReflection(); if ($class === null) { return []; @@ -105,7 +96,7 @@ public function processNode(Node $node, Scope $scope): array 'Property %s::$%s: Doctrine type "%s" does not have any registered descriptor.', $className, $propertyName, - $fieldMapping['type'] + $fieldMapping['type'], ))->identifier('doctrine.descriptorNotFound')->build(), ] : []; } @@ -115,25 +106,77 @@ public function processNode(Node $node, Scope $scope): array $enumTypeString = $fieldMapping['enumType'] ?? null; if ($enumTypeString !== null) { - if ($this->reflectionProvider->hasClass($enumTypeString)) { - $enumReflection = $this->reflectionProvider->getClass($enumTypeString); - $backedEnumType = $enumReflection->getBackedEnumType(); - if ($backedEnumType !== null) { - if (!$backedEnumType->equals($writableToDatabaseType) || !$backedEnumType->equals($writableToPropertyType)) { - $errors[] = RuleErrorBuilder::message(sprintf( - 'Property %s::$%s type mapping mismatch: backing type %s of enum %s does not match database type %s.', - $className, - $propertyName, - $backedEnumType->describe(VerbosityLevel::typeOnly()), - $enumReflection->getDisplayName(), - $writableToDatabaseType->describe(VerbosityLevel::typeOnly()) - ))->identifier('doctrine.enumType')->build(); + if ($writableToDatabaseType->isArray()->no() && $writableToPropertyType->isArray()->no()) { + if ($this->reflectionProvider->hasClass($enumTypeString)) { + $enumReflection = $this->reflectionProvider->getClass($enumTypeString); + $backedEnumType = $enumReflection->getBackedEnumType(); + if ($backedEnumType !== null) { + if (!$backedEnumType->equals($writableToDatabaseType) || !$backedEnumType->equals($writableToPropertyType)) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Property %s::$%s type mapping mismatch: backing type %s of enum %s does not match database type %s.', + $className, + $propertyName, + $backedEnumType->describe(VerbosityLevel::typeOnly()), + $enumReflection->getDisplayName(), + $writableToDatabaseType->describe(VerbosityLevel::typeOnly()), + ))->identifier('doctrine.enumType')->build(); + } } } + $enumType = new ObjectType($enumTypeString); + $writableToPropertyType = $enumType; + $writableToDatabaseType = $enumType; + } else { + $enumType = new ObjectType($enumTypeString); + if ($this->reflectionProvider->hasClass($enumTypeString)) { + $enumReflection = $this->reflectionProvider->getClass($enumTypeString); + $backedEnumType = $enumReflection->getBackedEnumType(); + if ($backedEnumType !== null) { + if (!$backedEnumType->equals($writableToDatabaseType->getIterableValueType()) || !$backedEnumType->equals($writableToPropertyType->getIterableValueType())) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Property %s::$%s type mapping mismatch: backing type %s of enum %s does not match value type %s of the database type %s.', + $className, + $propertyName, + $backedEnumType->describe(VerbosityLevel::typeOnly()), + $enumReflection->getDisplayName(), + $writableToDatabaseType->getIterableValueType()->describe(VerbosityLevel::typeOnly()), + $writableToDatabaseType->describe(VerbosityLevel::typeOnly()), + ), + )->identifier('doctrine.enumType')->build(); + } + } + } + + $writableToPropertyType = TypeCombinator::intersect(new ArrayType( + $writableToPropertyType->getIterableKeyType(), + $enumType, + ), ...TypeUtils::getAccessoryTypes($writableToPropertyType)); + $writableToDatabaseType = TypeCombinator::intersect(new ArrayType( + $writableToDatabaseType->getIterableKeyType(), + $enumType, + ), ...TypeUtils::getAccessoryTypes($writableToDatabaseType)); + + } + } elseif ($fieldMapping['type'] === 'enum') { + $values = $fieldMapping['options']['values'] ?? null; + if (is_array($values)) { + $enumTypes = []; + foreach ($values as $value) { + if (!is_string($value)) { + $enumTypes = []; + break; + } + + $enumTypes[] = new ConstantStringType($value); + } + + if (count($enumTypes) > 0) { + $unionType = TypeCombinator::union(...$enumTypes); + $writableToPropertyType = $unionType; + $writableToDatabaseType = $unionType; + } } - $enumType = new ObjectType($enumTypeString); - $writableToPropertyType = $enumType; - $writableToDatabaseType = $enumType; } $identifiers = []; @@ -155,7 +198,7 @@ public function processNode(Node $node, Scope $scope): array } $phpDocType = $node->getPhpDocType(); - $nativeType = $node->getNativeType() !== null ? ParserNodeTypeToPHPStanType::resolve($node->getNativeType(), $scope->getClassReflection()) : new MixedType(); + $nativeType = $node->getNativeType() ?? new MixedType(); $propertyType = TypehintHelper::decideType($nativeType, $phpDocType); if (get_class($propertyType) === MixedType::class || $propertyType instanceof ErrorType || $propertyType instanceof NeverType) { @@ -177,7 +220,7 @@ public function processNode(Node $node, Scope $scope): array $className, $propertyName, $writableToPropertyType->describe(VerbosityLevel::getRecommendedLevelByType($propertyTransformedType, $writableToPropertyType)), - $propertyType->describe(VerbosityLevel::getRecommendedLevelByType($propertyTransformedType, $writableToPropertyType)) + $propertyType->describe(VerbosityLevel::getRecommendedLevelByType($propertyTransformedType, $writableToPropertyType)), ))->identifier('doctrine.columnType')->build(); } @@ -185,7 +228,7 @@ public function processNode(Node $node, Scope $scope): array !$writableToDatabaseType->isSuperTypeOf( $this->allowNullablePropertyForRequiredField || (in_array($propertyName, $identifiers, true) && !$nullable) ? TypeCombinator::removeNull($propertyType) - : $propertyType + : $propertyType, )->yes() ) { $errors[] = RuleErrorBuilder::message(sprintf( @@ -193,7 +236,7 @@ public function processNode(Node $node, Scope $scope): array $className, $propertyName, $propertyTransformedType->describe(VerbosityLevel::getRecommendedLevelByType($writableToDatabaseType, $propertyType)), - $writableToDatabaseType->describe(VerbosityLevel::getRecommendedLevelByType($writableToDatabaseType, $propertyType)) + $writableToDatabaseType->describe(VerbosityLevel::getRecommendedLevelByType($writableToDatabaseType, $propertyType)), ))->identifier('doctrine.columnType')->build(); } return $errors; diff --git a/src/Rules/Doctrine/ORM/EntityConstructorNotFinalRule.php b/src/Rules/Doctrine/ORM/EntityConstructorNotFinalRule.php index 5fd01213..24876b38 100644 --- a/src/Rules/Doctrine/ORM/EntityConstructorNotFinalRule.php +++ b/src/Rules/Doctrine/ORM/EntityConstructorNotFinalRule.php @@ -17,8 +17,7 @@ class EntityConstructorNotFinalRule implements Rule { - /** @var ObjectMetadataResolver */ - private $objectMetadataResolver; + private ObjectMetadataResolver $objectMetadataResolver; public function __construct(ObjectMetadataResolver $objectMetadataResolver) { @@ -57,7 +56,7 @@ public function processNode(Node $node, Scope $scope): array return [ RuleErrorBuilder::message(sprintf( 'Constructor of class %s is final which can cause problems with proxies.', - $classReflection->getDisplayName() + $classReflection->getDisplayName(), ))->identifier('doctrine.finalConstructor')->build(), ]; } diff --git a/src/Rules/Doctrine/ORM/EntityMappingExceptionRule.php b/src/Rules/Doctrine/ORM/EntityMappingExceptionRule.php index d74019ba..d0ce97f7 100644 --- a/src/Rules/Doctrine/ORM/EntityMappingExceptionRule.php +++ b/src/Rules/Doctrine/ORM/EntityMappingExceptionRule.php @@ -18,8 +18,7 @@ class EntityMappingExceptionRule implements Rule { - /** @var ObjectMetadataResolver */ - private $objectMetadataResolver; + private ObjectMetadataResolver $objectMetadataResolver; public function __construct( ObjectMetadataResolver $objectMetadataResolver diff --git a/src/Rules/Doctrine/ORM/EntityNotFinalRule.php b/src/Rules/Doctrine/ORM/EntityNotFinalRule.php index b4dfe4c7..9dd39ff3 100644 --- a/src/Rules/Doctrine/ORM/EntityNotFinalRule.php +++ b/src/Rules/Doctrine/ORM/EntityNotFinalRule.php @@ -17,8 +17,7 @@ class EntityNotFinalRule implements Rule { - /** @var ObjectMetadataResolver */ - private $objectMetadataResolver; + private ObjectMetadataResolver $objectMetadataResolver; public function __construct(ObjectMetadataResolver $objectMetadataResolver) { @@ -52,7 +51,7 @@ public function processNode(Node $node, Scope $scope): array return [ RuleErrorBuilder::message(sprintf( 'Entity class %s is final which can cause problems with proxies.', - $classReflection->getDisplayName() + $classReflection->getDisplayName(), ))->identifier('doctrine.finalEntity')->build(), ]; } diff --git a/src/Rules/Doctrine/ORM/EntityRelationRule.php b/src/Rules/Doctrine/ORM/EntityRelationRule.php index 69e0c2a5..84ce9120 100644 --- a/src/Rules/Doctrine/ORM/EntityRelationRule.php +++ b/src/Rules/Doctrine/ORM/EntityRelationRule.php @@ -13,7 +13,6 @@ use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; -use PHPStan\Type\ParserNodeTypeToPHPStanType; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypehintHelper; use PHPStan\Type\VerbosityLevel; @@ -28,24 +27,17 @@ class EntityRelationRule implements Rule { - /** @var ObjectMetadataResolver */ - private $objectMetadataResolver; + private ObjectMetadataResolver $objectMetadataResolver; - /** @var bool */ - private $allowNullablePropertyForRequiredField; - - /** @var bool */ - private $bleedingEdge; + private bool $allowNullablePropertyForRequiredField; public function __construct( ObjectMetadataResolver $objectMetadataResolver, - bool $allowNullablePropertyForRequiredField, - bool $bleedingEdge + bool $allowNullablePropertyForRequiredField ) { $this->objectMetadataResolver = $objectMetadataResolver; $this->allowNullablePropertyForRequiredField = $allowNullablePropertyForRequiredField; - $this->bleedingEdge = $bleedingEdge; } public function getNodeType(): string @@ -55,10 +47,6 @@ public function getNodeType(): string public function processNode(Node $node, Scope $scope): array { - if (!$this->bleedingEdge && !$this->objectMetadataResolver->hasObjectManagerLoader()) { - return []; - } - $class = $scope->getClassReflection(); if ($class === null) { return []; @@ -102,12 +90,12 @@ public function processNode(Node $node, Scope $scope): array $toMany = true; $columnType = TypeCombinator::intersect( new ObjectType('Doctrine\Common\Collections\Collection'), - new IterableType(new MixedType(), new ObjectType($associationMapping['targetEntity'])) + new IterableType(new MixedType(), new ObjectType($associationMapping['targetEntity'])), ); } $phpDocType = $node->getPhpDocType(); - $nativeType = $node->getNativeType() !== null ? ParserNodeTypeToPHPStanType::resolve($node->getNativeType(), $scope->getClassReflection()) : new MixedType(); + $nativeType = $node->getNativeType() ?? new MixedType(); $propertyType = TypehintHelper::decideType($nativeType, $phpDocType); $errors = []; @@ -125,7 +113,7 @@ public function processNode(Node $node, Scope $scope): array ) { $propertyTypeToCheckAgainst = TypeCombinator::intersect( $collectionObjectType, - new IterableType(new MixedType(true), $propertyType->getIterableValueType()) + new IterableType(new MixedType(true), $propertyType->getIterableValueType()), ); } if (!$propertyTypeToCheckAgainst->isSuperTypeOf($columnType)->yes()) { @@ -134,14 +122,14 @@ public function processNode(Node $node, Scope $scope): array $className, $propertyName, $columnType->describe(VerbosityLevel::typeOnly()), - $propertyType->describe(VerbosityLevel::typeOnly()) + $propertyType->describe(VerbosityLevel::typeOnly()), ))->identifier('doctrine.associationType')->build(); } if ( !$columnType->isSuperTypeOf( $this->allowNullablePropertyForRequiredField ? TypeCombinator::removeNull($propertyType) - : $propertyType + : $propertyType, )->yes() ) { $errors[] = RuleErrorBuilder::message(sprintf( @@ -149,7 +137,7 @@ public function processNode(Node $node, Scope $scope): array $className, $propertyName, $propertyType->describe(VerbosityLevel::typeOnly()), - $columnType->describe(VerbosityLevel::typeOnly()) + $columnType->describe(VerbosityLevel::typeOnly()), ))->identifier('doctrine.associationType')->build(); } } diff --git a/src/Rules/Doctrine/ORM/PropertiesExtension.php b/src/Rules/Doctrine/ORM/PropertiesExtension.php index 4fdbf57d..9eab7dd7 100644 --- a/src/Rules/Doctrine/ORM/PropertiesExtension.php +++ b/src/Rules/Doctrine/ORM/PropertiesExtension.php @@ -12,8 +12,7 @@ class PropertiesExtension implements ReadWritePropertiesExtension { - /** @var ObjectMetadataResolver */ - private $objectMetadataResolver; + private ObjectMetadataResolver $objectMetadataResolver; public function __construct(ObjectMetadataResolver $objectMetadataResolver) { diff --git a/src/Rules/Doctrine/ORM/QueryBuilderDqlRule.php b/src/Rules/Doctrine/ORM/QueryBuilderDqlRule.php index 0cc99224..69f9791a 100644 --- a/src/Rules/Doctrine/ORM/QueryBuilderDqlRule.php +++ b/src/Rules/Doctrine/ORM/QueryBuilderDqlRule.php @@ -13,7 +13,6 @@ use PHPStan\Type\Doctrine\DoctrineTypeUtils; use PHPStan\Type\Doctrine\ObjectMetadataResolver; use PHPStan\Type\ObjectType; -use PHPStan\Type\TypeUtils; use Throwable; use function array_values; use function count; @@ -26,11 +25,9 @@ class QueryBuilderDqlRule implements Rule { - /** @var ObjectMetadataResolver */ - private $objectMetadataResolver; + private ObjectMetadataResolver $objectMetadataResolver; - /** @var bool */ - private $reportDynamicQueryBuilders; + private bool $reportDynamicQueryBuilders; public function __construct( ObjectMetadataResolver $objectMetadataResolver, @@ -83,7 +80,7 @@ public function processNode(Node $node, Scope $scope): array ]; } - $dqls = TypeUtils::getConstantStrings($dqlType); + $dqls = $dqlType->getConstantStrings(); if (count($dqls) === 0) { if ($this->reportDynamicQueryBuilders) { return [ @@ -118,10 +115,15 @@ public function processNode(Node $node, Scope $scope): array $message .= sprintf("\nDQL: %s", $dql->getValue()); } + $builder = RuleErrorBuilder::message($message) + ->identifier('doctrine.dql'); + + if (count($dqls) > 1) { + $builder->addTip('Detected from DQL branch: ' . $dql->getValue()); + } + // Use message as index to prevent duplicate - $messages[$message] = RuleErrorBuilder::message($message) - ->identifier('doctrine.dql') - ->build(); + $messages[$message] = $builder->build(); } catch (AssertionError $e) { continue; } diff --git a/src/Rules/Doctrine/ORM/RepositoryMethodCallRule.php b/src/Rules/Doctrine/ORM/RepositoryMethodCallRule.php index 9061d6c4..9f5231fe 100644 --- a/src/Rules/Doctrine/ORM/RepositoryMethodCallRule.php +++ b/src/Rules/Doctrine/ORM/RepositoryMethodCallRule.php @@ -19,8 +19,7 @@ class RepositoryMethodCallRule implements Rule { - /** @var ObjectMetadataResolver */ - private $objectMetadataResolver; + private ObjectMetadataResolver $objectMetadataResolver; public function __construct(ObjectMetadataResolver $objectMetadataResolver) { @@ -82,7 +81,7 @@ public function processNode(Node $node, Scope $scope): array $calledOnType->describe(VerbosityLevel::typeOnly()), $methodName, $entityClassNames[0], - $fieldName->getValue() + $fieldName->getValue(), ))->identifier(sprintf('doctrine.%sArgument', $methodName))->build(); } } diff --git a/src/Rules/Gedmo/PropertiesExtension.php b/src/Rules/Gedmo/PropertiesExtension.php index d43b4308..e82f3bc5 100644 --- a/src/Rules/Gedmo/PropertiesExtension.php +++ b/src/Rules/Gedmo/PropertiesExtension.php @@ -41,11 +41,9 @@ class PropertiesExtension implements ReadWritePropertiesExtension Gedmo\Language::class, ]; - /** @var AnnotationReader|null */ - private $annotationReader; + private ?AnnotationReader $annotationReader = null; - /** @var ObjectMetadataResolver */ - private $objectMetadataResolver; + private ObjectMetadataResolver $objectMetadataResolver; public function __construct(ObjectMetadataResolver $objectMetadataResolver) { diff --git a/src/Stubs/Doctrine/StubFilesExtensionLoader.php b/src/Stubs/Doctrine/StubFilesExtensionLoader.php index 05732ce3..fc4b48aa 100644 --- a/src/Stubs/Doctrine/StubFilesExtensionLoader.php +++ b/src/Stubs/Doctrine/StubFilesExtensionLoader.php @@ -9,49 +9,33 @@ use PHPStan\PhpDoc\StubFilesExtension; use function class_exists; use function dirname; -use function file_exists; use function strpos; class StubFilesExtensionLoader implements StubFilesExtension { - /** @var Reflector */ - private $reflector; - - /** @var bool */ - private $bleedingEdge; + private Reflector $reflector; public function __construct( - Reflector $reflector, - bool $bleedingEdge + Reflector $reflector ) { $this->reflector = $reflector; - $this->bleedingEdge = $bleedingEdge; } public function getFiles(): array { $stubsDir = dirname(dirname(dirname(__DIR__))) . '/stubs'; - $path = $stubsDir; - - if ($this->bleedingEdge === true) { - $path .= '/bleedingEdge'; - } - $files = []; - if (file_exists($path . '/DBAL/Connection4.stub') && $this->isInstalledVersion('doctrine/dbal', 4)) { - $files[] = $path . '/DBAL/Connection4.stub'; - $files[] = $path . '/DBAL/ArrayParameterType.stub'; - $files[] = $path . '/DBAL/ParameterType.stub'; + if ($this->isInstalledVersion('doctrine/dbal', 4)) { + $files[] = $stubsDir . '/DBAL/Connection4.stub'; + $files[] = $stubsDir . '/DBAL/ArrayParameterType.stub'; + $files[] = $stubsDir . '/DBAL/ParameterType.stub'; } else { - $files[] = $path . '/DBAL/Connection.stub'; + $files[] = $stubsDir . '/DBAL/Connection.stub'; } - $files[] = $path . '/ORM/QueryBuilder.stub'; - $files[] = $path . '/EntityRepository.stub'; - $hasLazyServiceEntityRepositoryAsParent = false; try { @@ -71,6 +55,21 @@ public function getFiles(): array $files[] = $stubsDir . '/ServiceEntityRepository.stub'; } + try { + $collectionVersion = class_exists(InstalledVersions::class) + ? InstalledVersions::getVersion('doctrine/collections') + : null; + } catch (OutOfBoundsException $e) { + $collectionVersion = null; + } + if ($collectionVersion !== null && strpos($collectionVersion, '1.') === 0) { + $files[] = $stubsDir . '/Collections/ReadableCollection1.stub'; + $files[] = $stubsDir . '/Collections/Collection1.stub'; + } else { + $files[] = $stubsDir . '/Collections/ReadableCollection.stub'; + $files[] = $stubsDir . '/Collections/Collection.stub'; + } + return $files; } diff --git a/src/Type/Doctrine/ArgumentsProcessor.php b/src/Type/Doctrine/ArgumentsProcessor.php index f2f88b82..2cd94fd7 100644 --- a/src/Type/Doctrine/ArgumentsProcessor.php +++ b/src/Type/Doctrine/ArgumentsProcessor.php @@ -13,8 +13,7 @@ class ArgumentsProcessor { - /** @var ObjectMetadataResolver */ - private $objectMetadataResolver; + private ObjectMetadataResolver $objectMetadataResolver; public function __construct(ObjectMetadataResolver $objectMetadataResolver) { @@ -34,6 +33,9 @@ public function processArgs( { $args = []; foreach ($methodCallArgs as $arg) { + if ($arg->unpack) { + throw new DynamicQueryBuilderArgumentException(); + } $value = $scope->getType($arg->value); if ( $value instanceof ExprType @@ -56,7 +58,7 @@ public function processArgs( continue; } - if ($value->isClassStringType()->yes() && count($value->getClassStringObjectType()->getObjectClassNames()) === 1) { + if ($value->isClassString()->yes() && count($value->getClassStringObjectType()->getObjectClassNames()) === 1) { /** @var class-string $className */ $className = $value->getClassStringObjectType()->getObjectClassNames()[0]; if ($this->objectMetadataResolver->isTransient($className)) { diff --git a/src/Type/Doctrine/Collection/IsEmptyTypeSpecifyingExtension.php b/src/Type/Doctrine/Collection/IsEmptyTypeSpecifyingExtension.php index 0d440940..9a177304 100644 --- a/src/Type/Doctrine/Collection/IsEmptyTypeSpecifyingExtension.php +++ b/src/Type/Doctrine/Collection/IsEmptyTypeSpecifyingExtension.php @@ -19,11 +19,10 @@ final class IsEmptyTypeSpecifyingExtension implements MethodTypeSpecifyingExtens private const FIRST_METHOD_NAME = 'first'; private const LAST_METHOD_NAME = 'last'; - /** @var TypeSpecifier */ - private $typeSpecifier; + private TypeSpecifier $typeSpecifier; /** @var class-string */ - private $collectionClass; + private string $collectionClass; /** * @param class-string $collectionClass @@ -44,11 +43,8 @@ public function isMethodSupported( TypeSpecifierContext $context ): bool { - return ( - $methodReflection->getDeclaringClass()->getName() === $this->collectionClass - || $methodReflection->getDeclaringClass()->isSubclassOf($this->collectionClass) - ) - && $methodReflection->getName() === self::IS_EMPTY_METHOD_NAME; + return $methodReflection->getDeclaringClass()->is($this->collectionClass) + && $methodReflection->getName() === self::IS_EMPTY_METHOD_NAME; } public function specifyTypes( @@ -61,13 +57,15 @@ public function specifyTypes( $first = $this->typeSpecifier->create( new MethodCall($node->var, self::FIRST_METHOD_NAME), new ConstantBooleanType(false), - $context + $context, + $scope, ); $last = $this->typeSpecifier->create( new MethodCall($node->var, self::LAST_METHOD_NAME), new ConstantBooleanType(false), - $context + $context, + $scope, ); return $first->unionWith($last); diff --git a/src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php b/src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php index 4c41613c..b78f8467 100644 --- a/src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php @@ -12,6 +12,8 @@ use Doctrine\Persistence\Mapping\MappingException; use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; +use PHPStan\Doctrine\Driver\DriverDetector; +use PHPStan\Php\PhpVersion; use PHPStan\Reflection\MethodReflection; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\Doctrine\Query\QueryResultTypeBuilder; @@ -31,16 +33,25 @@ final class CreateQueryDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension { - /** @var ObjectMetadataResolver */ - private $objectMetadataResolver; + private ObjectMetadataResolver $objectMetadataResolver; - /** @var DescriptorRegistry */ - private $descriptorRegistry; + private DescriptorRegistry $descriptorRegistry; - public function __construct(ObjectMetadataResolver $objectMetadataResolver, DescriptorRegistry $descriptorRegistry) + private PhpVersion $phpVersion; + + private DriverDetector $driverDetector; + + public function __construct( + ObjectMetadataResolver $objectMetadataResolver, + DescriptorRegistry $descriptorRegistry, + PhpVersion $phpVersion, + DriverDetector $driverDetector + ) { $this->objectMetadataResolver = $objectMetadataResolver; $this->descriptorRegistry = $descriptorRegistry; + $this->phpVersion = $phpVersion; + $this->driverDetector = $driverDetector; } public function getClass(): string @@ -65,7 +76,7 @@ public function getTypeFromMethodCall( if (!isset($args[$queryStringArgIndex])) { return new GenericObjectType( Query::class, - [new MixedType(), new MixedType()] + [new MixedType(), new MixedType()], ); } @@ -87,7 +98,7 @@ public function getTypeFromMethodCall( try { $query = $em->createQuery($queryString); - QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry); + QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry, $this->phpVersion, $this->driverDetector); } catch (ORMException | DBALException | NewDBALException | CommonException | MappingException | \Doctrine\ORM\Exception\ORMException $e) { return new QueryType($queryString, null, null); } catch (AssertionError $e) { @@ -98,7 +109,7 @@ public function getTypeFromMethodCall( } return new GenericObjectType( Query::class, - [new MixedType(), new MixedType()] + [new MixedType(), new MixedType()], ); }); } diff --git a/src/Type/Doctrine/DBAL/QueryBuilder/QueryBuilderExecuteMethodExtension.php b/src/Type/Doctrine/DBAL/QueryBuilder/QueryBuilderExecuteMethodExtension.php index 31170cfc..0a644f3a 100644 --- a/src/Type/Doctrine/DBAL/QueryBuilder/QueryBuilderExecuteMethodExtension.php +++ b/src/Type/Doctrine/DBAL/QueryBuilder/QueryBuilderExecuteMethodExtension.php @@ -19,8 +19,7 @@ class QueryBuilderExecuteMethodExtension implements DynamicMethodReturnTypeExtension { - /** @var ReflectionProvider */ - private $reflectionProvider; + private ReflectionProvider $reflectionProvider; public function __construct(ReflectionProvider $reflectionProvider) { @@ -39,7 +38,11 @@ public function isMethodSupported(MethodReflection $methodReflection): bool public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type { - $defaultReturnType = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); + $defaultReturnType = ParametersAcceptorSelector::selectFromArgs( + $scope, + $methodCall->getArgs(), + $methodReflection->getVariants(), + )->getReturnType(); $queryBuilderType = new ObjectType(QueryBuilder::class); $var = $methodCall->var; diff --git a/src/Type/Doctrine/DBAL/RowCountMethodDynamicReturnTypeExtension.php b/src/Type/Doctrine/DBAL/RowCountMethodDynamicReturnTypeExtension.php new file mode 100644 index 00000000..ac6f38b4 --- /dev/null +++ b/src/Type/Doctrine/DBAL/RowCountMethodDynamicReturnTypeExtension.php @@ -0,0 +1,112 @@ +class = $class; + $this->objectMetadataResolver = $objectMetadataResolver; + $this->driverDetector = $driverDetector; + $this->reflectionProvider = $reflectionProvider; + } + + public function getClass(): string + { + return $this->class; + } + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'rowCount'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + $objectManager = $this->objectMetadataResolver->getObjectManager(); + if (!$objectManager instanceof EntityManagerInterface) { + return null; + } + + $connection = $objectManager->getConnection(); + $driver = $this->driverDetector->detect($connection); + if ($driver === null) { + return null; + } + + $resultClass = $this->getResultClass($driver); + + if (!$this->reflectionProvider->hasClass($resultClass)) { + return null; + } + + $resultReflection = $this->reflectionProvider->getClass($resultClass); + if (!$resultReflection->hasNativeMethod('rowCount')) { + return null; + } + + $rowCountMethod = $resultReflection->getNativeMethod('rowCount'); + $variant = $rowCountMethod->getOnlyVariant(); + + return $variant->getReturnType(); + } + + /** + * @param DriverDetector::* $driver + * @return class-string + */ + private function getResultClass(string $driver): string + { + switch ($driver) { + case DriverDetector::IBM_DB2: + return 'Doctrine\DBAL\Driver\IBMDB2\Result'; + case DriverDetector::MYSQLI: + return 'Doctrine\DBAL\Driver\Mysqli\Result'; + case DriverDetector::OCI8: + return 'Doctrine\DBAL\Driver\OCI8\Result'; + case DriverDetector::PDO_MYSQL: + case DriverDetector::PDO_OCI: + case DriverDetector::PDO_PGSQL: + case DriverDetector::PDO_SQLITE: + case DriverDetector::PDO_SQLSRV: + return 'Doctrine\DBAL\Driver\PDO\Result'; + case DriverDetector::PGSQL: + return 'Doctrine\DBAL\Driver\PgSQL\Result'; // @phpstan-ignore return.type + case DriverDetector::SQLITE3: + return 'Doctrine\DBAL\Driver\SQLite3\Result'; // @phpstan-ignore return.type + case DriverDetector::SQLSRV: + return 'Doctrine\DBAL\Driver\SQLSrv\Result'; + } + } + +} diff --git a/src/Type/Doctrine/DefaultDescriptorRegistry.php b/src/Type/Doctrine/DefaultDescriptorRegistry.php index 48886caa..74ea184d 100644 --- a/src/Type/Doctrine/DefaultDescriptorRegistry.php +++ b/src/Type/Doctrine/DefaultDescriptorRegistry.php @@ -9,7 +9,7 @@ class DefaultDescriptorRegistry implements DescriptorRegistry { /** @var array, DoctrineTypeDescriptor> */ - private $descriptors = []; + private array $descriptors = []; /** * @param DoctrineTypeDescriptor[] $descriptors @@ -25,15 +25,26 @@ public function get(string $type): DoctrineTypeDescriptor { $typesMap = Type::getTypesMap(); if (!isset($typesMap[$type])) { - throw new DescriptorNotRegisteredException(); + throw new DescriptorNotRegisteredException($type); } /** @var class-string $typeClass */ $typeClass = $typesMap[$type]; if (!isset($this->descriptors[$typeClass])) { - throw new DescriptorNotRegisteredException(); + throw new DescriptorNotRegisteredException($typeClass); } return $this->descriptors[$typeClass]; } + /** + * @throws DescriptorNotRegisteredException + */ + public function getByClassName(string $className): DoctrineTypeDescriptor + { + if (!isset($this->descriptors[$className])) { + throw new DescriptorNotRegisteredException($className); + } + return $this->descriptors[$className]; + } + } diff --git a/src/Type/Doctrine/DescriptorRegistryFactory.php b/src/Type/Doctrine/DescriptorRegistryFactory.php index 3e7dd190..2dbf70aa 100644 --- a/src/Type/Doctrine/DescriptorRegistryFactory.php +++ b/src/Type/Doctrine/DescriptorRegistryFactory.php @@ -9,8 +9,7 @@ class DescriptorRegistryFactory public const TYPE_DESCRIPTOR_TAG = 'phpstan.doctrine.typeDescriptor'; - /** @var Container */ - private $container; + private Container $container; public function __construct(Container $container) { diff --git a/src/Type/Doctrine/Descriptors/BigIntType.php b/src/Type/Doctrine/Descriptors/BigIntType.php index 14b3ca2a..01b85eff 100644 --- a/src/Type/Doctrine/Descriptors/BigIntType.php +++ b/src/Type/Doctrine/Descriptors/BigIntType.php @@ -3,7 +3,6 @@ namespace PHPStan\Type\Doctrine\Descriptors; use Composer\InstalledVersions; -use PHPStan\Type\Accessory\AccessoryNumericStringType; use PHPStan\Type\IntegerType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -25,7 +24,7 @@ public function getWritableToPropertyType(): Type return new IntegerType(); } - return TypeCombinator::intersect(new StringType(), new AccessoryNumericStringType()); + return (new IntegerType())->toString(); } public function getWritableToDatabaseType(): Type diff --git a/src/Type/Doctrine/Descriptors/BinaryType.php b/src/Type/Doctrine/Descriptors/BinaryType.php index 5b3c848a..21a38092 100644 --- a/src/Type/Doctrine/Descriptors/BinaryType.php +++ b/src/Type/Doctrine/Descriptors/BinaryType.php @@ -2,10 +2,13 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Composer\InstalledVersions; use PHPStan\Type\MixedType; use PHPStan\Type\ResourceType; use PHPStan\Type\StringType; use PHPStan\Type\Type; +use function class_exists; +use function strpos; class BinaryType implements DoctrineTypeDescriptor { @@ -17,6 +20,10 @@ public function getType(): string public function getWritableToPropertyType(): Type { + if ($this->hasDbal4()) { + return new StringType(); + } + return new ResourceType(); } @@ -30,4 +37,18 @@ public function getDatabaseInternalType(): Type return new StringType(); } + private function hasDbal4(): bool + { + if (!class_exists(InstalledVersions::class)) { + return false; + } + + $dbalVersion = InstalledVersions::getVersion('doctrine/dbal'); + if ($dbalVersion === null) { + return false; + } + + return strpos($dbalVersion, '4.') === 0; + } + } diff --git a/src/Type/Doctrine/Descriptors/BooleanType.php b/src/Type/Doctrine/Descriptors/BooleanType.php index 0e6980c0..54e7f662 100644 --- a/src/Type/Doctrine/Descriptors/BooleanType.php +++ b/src/Type/Doctrine/Descriptors/BooleanType.php @@ -2,13 +2,23 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Doctrine\DBAL\Connection; +use PHPStan\Doctrine\Driver\DriverDetector; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use function in_array; -class BooleanType implements DoctrineTypeDescriptor +class BooleanType implements DoctrineTypeDescriptor, DoctrineTypeDriverAwareDescriptor { + private DriverDetector $driverDetector; + + public function __construct(DriverDetector $driverDetector) + { + $this->driverDetector = $driverDetector; + } + public function getType(): string { return \Doctrine\DBAL\Types\BooleanType::class; @@ -28,8 +38,33 @@ public function getDatabaseInternalType(): Type { return TypeCombinator::union( new ConstantIntegerType(0), - new ConstantIntegerType(1) + new ConstantIntegerType(1), + new \PHPStan\Type\BooleanType(), ); } + public function getDatabaseInternalTypeForDriver(Connection $connection): Type + { + $driverType = $this->driverDetector->detect($connection); + + if ($driverType === DriverDetector::PGSQL || $driverType === DriverDetector::PDO_PGSQL) { + return new \PHPStan\Type\BooleanType(); + } + + if (in_array($driverType, [ + DriverDetector::SQLITE3, + DriverDetector::PDO_SQLITE, + DriverDetector::MYSQLI, + DriverDetector::PDO_MYSQL, + ], true)) { + return TypeCombinator::union( + new ConstantIntegerType(0), + new ConstantIntegerType(1), + ); + } + + // not yet supported driver, return the old implementation guess + return $this->getDatabaseInternalType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/DecimalType.php b/src/Type/Doctrine/Descriptors/DecimalType.php index b008ffe5..0f0e16e3 100644 --- a/src/Type/Doctrine/Descriptors/DecimalType.php +++ b/src/Type/Doctrine/Descriptors/DecimalType.php @@ -2,16 +2,25 @@ namespace PHPStan\Type\Doctrine\Descriptors; -use PHPStan\Type\Accessory\AccessoryNumericStringType; +use Doctrine\DBAL\Connection; +use PHPStan\Doctrine\Driver\DriverDetector; use PHPStan\Type\FloatType; use PHPStan\Type\IntegerType; use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use function in_array; -class DecimalType implements DoctrineTypeDescriptor +class DecimalType implements DoctrineTypeDescriptor, DoctrineTypeDriverAwareDescriptor { + private DriverDetector $driverDetector; + + public function __construct(DriverDetector $driverDetector) + { + $this->driverDetector = $driverDetector; + } + public function getType(): string { return \Doctrine\DBAL\Types\DecimalType::class; @@ -19,7 +28,7 @@ public function getType(): string public function getWritableToPropertyType(): Type { - return TypeCombinator::intersect(new StringType(), new AccessoryNumericStringType()); + return (new FloatType())->toString(); } public function getWritableToDatabaseType(): Type @@ -32,4 +41,25 @@ public function getDatabaseInternalType(): Type return TypeCombinator::union(new FloatType(), new IntegerType()); } + public function getDatabaseInternalTypeForDriver(Connection $connection): Type + { + $driverType = $this->driverDetector->detect($connection); + + if ($driverType === DriverDetector::SQLITE3 || $driverType === DriverDetector::PDO_SQLITE) { + return TypeCombinator::union(new FloatType(), new IntegerType()); + } + + if (in_array($driverType, [ + DriverDetector::MYSQLI, + DriverDetector::PDO_MYSQL, + DriverDetector::PGSQL, + DriverDetector::PDO_PGSQL, + ], true)) { + return (new FloatType())->toString(); + } + + // not yet supported driver, return the old implementation guess + return $this->getDatabaseInternalType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/DoctrineTypeDescriptor.php b/src/Type/Doctrine/Descriptors/DoctrineTypeDescriptor.php index 75f56c9b..c0bffd92 100644 --- a/src/Type/Doctrine/Descriptors/DoctrineTypeDescriptor.php +++ b/src/Type/Doctrine/Descriptors/DoctrineTypeDescriptor.php @@ -13,10 +13,23 @@ interface DoctrineTypeDescriptor */ public function getType(): string; + /** + * This is used for inferring direct column results, e.g. SELECT e.field + * It should comply with convertToPHPValue return value + */ public function getWritableToPropertyType(): Type; public function getWritableToDatabaseType(): Type; + /** + * This is used for inferring how database fetches column of such type + * + * This is not used for direct column type inferring, + * but when such column appears in expression like SELECT MAX(e.field) + * + * Sometimes, the type cannot be reliably decided without driver context, + * use DoctrineTypeDriverAwareDescriptor in such cases + */ public function getDatabaseInternalType(): Type; } diff --git a/src/Type/Doctrine/Descriptors/DoctrineTypeDriverAwareDescriptor.php b/src/Type/Doctrine/Descriptors/DoctrineTypeDriverAwareDescriptor.php new file mode 100644 index 00000000..765c8f2e --- /dev/null +++ b/src/Type/Doctrine/Descriptors/DoctrineTypeDriverAwareDescriptor.php @@ -0,0 +1,29 @@ +driverDetector = $driverDetector; + } + public function getType(): string { return \Doctrine\DBAL\Types\FloatType::class; @@ -26,7 +36,29 @@ public function getWritableToDatabaseType(): Type public function getDatabaseInternalType(): Type { - return TypeCombinator::union(new \PHPStan\Type\FloatType(), new IntegerType()); + return TypeCombinator::union( + new \PHPStan\Type\FloatType(), + (new \PHPStan\Type\FloatType())->toString(), + ); + } + + public function getDatabaseInternalTypeForDriver(Connection $connection): Type + { + $driverType = $this->driverDetector->detect($connection); + + if (in_array($driverType, [ + DriverDetector::SQLITE3, + DriverDetector::PDO_SQLITE, + DriverDetector::MYSQLI, + DriverDetector::PDO_MYSQL, + DriverDetector::PDO_PGSQL, + DriverDetector::PGSQL, + ], true)) { + return new \PHPStan\Type\FloatType(); + } + + // not yet supported driver, return the old implementation guess + return $this->getDatabaseInternalType(); } } diff --git a/src/Type/Doctrine/Descriptors/Ramsey/UuidTypeDescriptor.php b/src/Type/Doctrine/Descriptors/Ramsey/UuidTypeDescriptor.php index 78501f2c..7b0d8bf5 100644 --- a/src/Type/Doctrine/Descriptors/Ramsey/UuidTypeDescriptor.php +++ b/src/Type/Doctrine/Descriptors/Ramsey/UuidTypeDescriptor.php @@ -2,47 +2,29 @@ namespace PHPStan\Type\Doctrine\Descriptors\Ramsey; -use PHPStan\Rules\Doctrine\ORM\FakeTestingUuidType; -use PHPStan\ShouldNotHappenException; use PHPStan\Type\Doctrine\Descriptors\DoctrineTypeDescriptor; use PHPStan\Type\ObjectType; use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use Ramsey\Uuid\UuidInterface; -use function in_array; -use function sprintf; class UuidTypeDescriptor implements DoctrineTypeDescriptor { - private const SUPPORTED_UUID_TYPES = [ - 'Ramsey\Uuid\Doctrine\UuidType', - 'Ramsey\Uuid\Doctrine\UuidBinaryType', - 'Ramsey\Uuid\Doctrine\UuidBinaryOrderedTimeType', - FakeTestingUuidType::class, - ]; + /** @var class-string<\Doctrine\DBAL\Types\Type> */ + private string $uuidTypeName; - /** @var string */ - private $uuidTypeName; - - public function __construct( - string $uuidTypeName - ) + /** + * @param class-string<\Doctrine\DBAL\Types\Type> $uuidTypeName + */ + public function __construct(string $uuidTypeName) { - if (!in_array($uuidTypeName, self::SUPPORTED_UUID_TYPES, true)) { - throw new ShouldNotHappenException(sprintf( - 'Unexpected UUID column type "%s" provided', - $uuidTypeName - )); - } - $this->uuidTypeName = $uuidTypeName; } public function getType(): string { - /** @var class-string<\Doctrine\DBAL\Types\Type> */ return $this->uuidTypeName; } @@ -55,7 +37,7 @@ public function getWritableToDatabaseType(): Type { return TypeCombinator::union( new StringType(), - new ObjectType(UuidInterface::class) + new ObjectType(UuidInterface::class), ); } diff --git a/src/Type/Doctrine/Descriptors/ReflectionDescriptor.php b/src/Type/Doctrine/Descriptors/ReflectionDescriptor.php index 283d9506..f4b5dba0 100644 --- a/src/Type/Doctrine/Descriptors/ReflectionDescriptor.php +++ b/src/Type/Doctrine/Descriptors/ReflectionDescriptor.php @@ -2,30 +2,41 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Doctrine\DBAL\Connection; use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Types\Type as DbalType; +use PHPStan\DependencyInjection\Container; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\Type\Doctrine\DefaultDescriptorRegistry; +use PHPStan\Type\Doctrine\DescriptorNotRegisteredException; use PHPStan\Type\MixedType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; -class ReflectionDescriptor implements DoctrineTypeDescriptor +class ReflectionDescriptor implements DoctrineTypeDescriptor, DoctrineTypeDriverAwareDescriptor { - /** @var class-string<\Doctrine\DBAL\Types\Type> */ + /** @var class-string */ private $type; - /** @var ReflectionProvider */ - private $reflectionProvider; + private ReflectionProvider $reflectionProvider; + + private Container $container; /** - * @param class-string<\Doctrine\DBAL\Types\Type> $type + * @param class-string $type */ - public function __construct(string $type, ReflectionProvider $reflectionProvider) + public function __construct( + string $type, + ReflectionProvider $reflectionProvider, + Container $container + ) { $this->type = $type; $this->reflectionProvider = $reflectionProvider; + $this->container = $container; } public function getType(): string @@ -57,6 +68,38 @@ public function getWritableToDatabaseType(): Type public function getDatabaseInternalType(): Type { + return $this->doGetDatabaseInternalType(null); + } + + public function getDatabaseInternalTypeForDriver(Connection $connection): Type + { + return $this->doGetDatabaseInternalType($connection); + } + + private function doGetDatabaseInternalType(?Connection $connection): Type + { + if (!$this->reflectionProvider->hasClass($this->type)) { + return new MixedType(); + } + + $registry = $this->container->getByType(DefaultDescriptorRegistry::class); + $parents = $this->reflectionProvider->getClass($this->type)->getParentClassesNames(); + + foreach ($parents as $dbalTypeParentClass) { + try { + // this assumes that if somebody inherits from DecimalType, + // the real database type remains decimal and we can reuse its descriptor + $descriptor = $registry->getByClassName($dbalTypeParentClass); + + return $descriptor instanceof DoctrineTypeDriverAwareDescriptor && $connection !== null + ? $descriptor->getDatabaseInternalTypeForDriver($connection) + : $descriptor->getDatabaseInternalType(); + + } catch (DescriptorNotRegisteredException $e) { + continue; + } + } + return new MixedType(); } diff --git a/src/Type/Doctrine/Descriptors/SimpleArrayType.php b/src/Type/Doctrine/Descriptors/SimpleArrayType.php index 8044caca..2154eb91 100644 --- a/src/Type/Doctrine/Descriptors/SimpleArrayType.php +++ b/src/Type/Doctrine/Descriptors/SimpleArrayType.php @@ -8,6 +8,7 @@ use PHPStan\Type\MixedType; use PHPStan\Type\StringType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; class SimpleArrayType implements DoctrineTypeDescriptor { @@ -19,7 +20,7 @@ public function getType(): string public function getWritableToPropertyType(): Type { - return AccessoryArrayListType::intersectWith(new ArrayType(new IntegerType(), new StringType())); + return TypeCombinator::intersect(new ArrayType(new IntegerType(), new StringType()), new AccessoryArrayListType()); } public function getWritableToDatabaseType(): Type diff --git a/src/Type/Doctrine/Descriptors/SmallFloatType.php b/src/Type/Doctrine/Descriptors/SmallFloatType.php new file mode 100644 index 00000000..6e107a7e --- /dev/null +++ b/src/Type/Doctrine/Descriptors/SmallFloatType.php @@ -0,0 +1,13 @@ + */ + private string $uuidTypeName; + + /** + * @param class-string<\Doctrine\DBAL\Types\Type> $uuidTypeName + */ + public function __construct(string $uuidTypeName) + { + $this->uuidTypeName = $uuidTypeName; + } + + public function getType(): string + { + return $this->uuidTypeName; + } + + public function getWritableToPropertyType(): Type + { + return new ObjectType(Ulid::class); + } + + public function getWritableToDatabaseType(): Type + { + return TypeCombinator::union( + new StringType(), + new ObjectType(Ulid::class), + ); + } + + public function getDatabaseInternalType(): Type + { + return new StringType(); + } + +} diff --git a/src/Type/Doctrine/Descriptors/Symfony/UuidTypeDescriptor.php b/src/Type/Doctrine/Descriptors/Symfony/UuidTypeDescriptor.php new file mode 100644 index 00000000..03151cec --- /dev/null +++ b/src/Type/Doctrine/Descriptors/Symfony/UuidTypeDescriptor.php @@ -0,0 +1,49 @@ + */ + private string $uuidTypeName; + + /** + * @param class-string<\Doctrine\DBAL\Types\Type> $uuidTypeName + */ + public function __construct(string $uuidTypeName) + { + $this->uuidTypeName = $uuidTypeName; + } + + public function getType(): string + { + return $this->uuidTypeName; + } + + public function getWritableToPropertyType(): Type + { + return new ObjectType(Uuid::class); + } + + public function getWritableToDatabaseType(): Type + { + return TypeCombinator::union( + new StringType(), + new ObjectType(Uuid::class), + ); + } + + public function getDatabaseInternalType(): Type + { + return new StringType(); + } + +} diff --git a/src/Type/Doctrine/EntityManagerInterfaceThrowTypeExtension.php b/src/Type/Doctrine/EntityManagerInterfaceThrowTypeExtension.php index 0070af9f..be3407c3 100644 --- a/src/Type/Doctrine/EntityManagerInterfaceThrowTypeExtension.php +++ b/src/Type/Doctrine/EntityManagerInterfaceThrowTypeExtension.php @@ -37,9 +37,7 @@ public function getThrowTypeFromMethodCall(MethodReflection $methodReflection, M if ((new ObjectType(EntityManagerInterface::class))->isSuperTypeOf($type)->yes()) { return TypeCombinator::union( - ...array_map(static function ($class): Type { - return new ObjectType($class); - }, self::SUPPORTED_METHOD[$methodReflection->getName()]) + ...array_map(static fn ($class): Type => new ObjectType($class), self::SUPPORTED_METHOD[$methodReflection->getName()]), ); } diff --git a/src/Type/Doctrine/GetRepositoryDynamicReturnTypeExtension.php b/src/Type/Doctrine/GetRepositoryDynamicReturnTypeExtension.php index fd90fdd5..07d40ffa 100644 --- a/src/Type/Doctrine/GetRepositoryDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/GetRepositoryDynamicReturnTypeExtension.php @@ -27,24 +27,22 @@ class GetRepositoryDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension { - /** @var ReflectionProvider */ - private $reflectionProvider; + private ReflectionProvider $reflectionProvider; - /** @var string|null */ - private $repositoryClass; + private ?string $repositoryClass = null; - /** @var string|null */ - private $ormRepositoryClass; + private ?string $ormRepositoryClass = null; - /** @var string|null */ - private $odmRepositoryClass; + private ?string $odmRepositoryClass = null; - /** @var string */ - private $managerClass; + /** @var class-string */ + private string $managerClass; - /** @var ObjectMetadataResolver */ - private $metadataResolver; + private ObjectMetadataResolver $metadataResolver; + /** + * @param class-string $managerClass + */ public function __construct( ReflectionProvider $reflectionProvider, ?string $repositoryClass, @@ -87,11 +85,11 @@ public function getTypeFromMethodCall( if (count($methodCall->getArgs()) === 0) { return new GenericObjectType( $defaultRepositoryClass, - [new ObjectWithoutClassType()] + [new ObjectWithoutClassType()], ); } $argType = $scope->getType($methodCall->getArgs()[0]->value); - if (!$argType->isClassStringType()->yes()) { + if (!$argType->isClassString()->yes()) { return $this->getDefaultReturnType($scope, $methodCall->getArgs(), $methodReflection, $defaultRepositoryClass); } @@ -101,7 +99,7 @@ public function getTypeFromMethodCall( if (count($objectNames) === 0) { return new GenericObjectType( $defaultRepositoryClass, - [$classType] + [$classType], ); } @@ -127,13 +125,13 @@ private function getDefaultReturnType(Scope $scope, array $args, MethodReflectio $defaultType = ParametersAcceptorSelector::selectFromArgs( $scope, $args, - $methodReflection->getVariants() + $methodReflection->getVariants(), )->getReturnType(); $entity = $defaultType->getTemplateType(ObjectRepository::class, 'TEntityClass'); if (!$entity instanceof ErrorType) { return new GenericObjectType( $defaultRepositoryClass, - [$entity] + [$entity], ); } diff --git a/src/Type/Doctrine/HydrationModeReturnTypeResolver.php b/src/Type/Doctrine/HydrationModeReturnTypeResolver.php new file mode 100644 index 00000000..e978ae2f --- /dev/null +++ b/src/Type/Doctrine/HydrationModeReturnTypeResolver.php @@ -0,0 +1,101 @@ +isSuperTypeOf($queryResultType); + + if ($isVoidType->yes()) { + // A void query result type indicates an UPDATE or DELETE query. + // In this case all methods return the number of affected rows. + return IntegerRangeType::fromInterval(0, null); + } + + if ($isVoidType->maybe()) { + // We can't be sure what the query type is, so we return the + // declared return type of the method. + return null; + } + + if (!$hydrationMode instanceof ConstantIntegerType) { + return null; + } + + switch ($hydrationMode->getValue()) { + case AbstractQuery::HYDRATE_OBJECT: + break; + case AbstractQuery::HYDRATE_SIMPLEOBJECT: + $queryResultType = $this->getSimpleObjectHydratedReturnType($queryResultType); + break; + default: + return null; + } + + if ($queryResultType === null) { + return null; + } + + switch ($methodName) { + case 'getSingleResult': + return $queryResultType; + case 'getOneOrNullResult': + $nullableQueryResultType = TypeCombinator::addNull($queryResultType); + if ($queryResultType instanceof BenevolentUnionType) { + $nullableQueryResultType = TypeUtils::toBenevolentUnion($nullableQueryResultType); + } + + return $nullableQueryResultType; + case 'toIterable': + return new IterableType( + $queryKeyType->isNull()->yes() ? new IntegerType() : $queryKeyType, + $queryResultType, + ); + default: + if ($queryKeyType->isNull()->yes()) { + return TypeCombinator::intersect(new ArrayType( + new IntegerType(), + $queryResultType, + ), new AccessoryArrayListType()); + } + return new ArrayType( + $queryKeyType, + $queryResultType, + ); + } + } + + private function getSimpleObjectHydratedReturnType(Type $queryResultType): ?Type + { + if ((new ObjectWithoutClassType())->isSuperTypeOf($queryResultType)->yes()) { + return $queryResultType; + } + + return null; + } + +} diff --git a/src/Type/Doctrine/ObjectMetadataResolver.php b/src/Type/Doctrine/ObjectMetadataResolver.php index 6a8b4fd2..054e9a57 100644 --- a/src/Type/Doctrine/ObjectMetadataResolver.php +++ b/src/Type/Doctrine/ObjectMetadataResolver.php @@ -17,17 +17,14 @@ final class ObjectMetadataResolver { - /** @var string|null */ - private $objectManagerLoader; + private ?string $objectManagerLoader = null; /** @var ObjectManager|false|null */ private $objectManager; - /** @var ClassMetadataFactory|null */ - private $metadataFactory; + private ?ClassMetadataFactory $metadataFactory = null; - /** @var string */ - private $tmpDir; + private string $tmpDir; public function __construct( ?string $objectManagerLoader, @@ -106,6 +103,8 @@ private function getMetadataFactory(): ?ClassMetadataFactory } /** + * @api + * * @template T of object * @param class-string $className * @return ClassMetadata|null @@ -150,14 +149,14 @@ private function loadObjectManager(string $objectManagerLoader): ?ObjectManager if (!is_file($objectManagerLoader)) { throw new ShouldNotHappenException(sprintf( 'Object manager could not be loaded: file "%s" does not exist', - $objectManagerLoader + $objectManagerLoader, )); } if (!is_readable($objectManagerLoader)) { throw new ShouldNotHappenException(sprintf( 'Object manager could not be loaded: file "%s" is not readable', - $objectManagerLoader + $objectManagerLoader, )); } diff --git a/src/Type/Doctrine/Query/DqlConstantStringType.php b/src/Type/Doctrine/Query/DqlConstantStringType.php new file mode 100644 index 00000000..f4cc4821 --- /dev/null +++ b/src/Type/Doctrine/Query/DqlConstantStringType.php @@ -0,0 +1,31 @@ +originLiteralType = $originLiteralType; + } + + /** + * @return Literal::* + */ + public function getOriginLiteralType(): int + { + return $this->originLiteralType; + } + +} diff --git a/src/Type/Doctrine/Query/QueryAggregateFunctionDetectorTreeWalker.php b/src/Type/Doctrine/Query/QueryAggregateFunctionDetectorTreeWalker.php new file mode 100644 index 00000000..7e891222 --- /dev/null +++ b/src/Type/Doctrine/Query/QueryAggregateFunctionDetectorTreeWalker.php @@ -0,0 +1,74 @@ +walkNode($selectStatement->selectClause); + } + + /** + * @param mixed $node + */ + public function walkNode($node): void + { + if (!$node instanceof AST\Node) { + return; + } + + if ($node instanceof AST\Subselect) { + return; + } + + if ($this->isAggregateFunction($node)) { + $this->markAggregateFunctionFound(); + return; + } + + foreach ((array) $node as $property) { + if ($property instanceof AST\Node) { + $this->walkNode($property); + } + + if (is_array($property)) { + foreach ($property as $propertyValue) { + $this->walkNode($propertyValue); + } + } + + if ($this->wasAggregateFunctionFound()) { + return; + } + } + } + + private function isAggregateFunction(AST\Node $node): bool + { + return $node instanceof AST\Functions\AvgFunction + || $node instanceof AST\Functions\CountFunction + || $node instanceof AST\Functions\MaxFunction + || $node instanceof AST\Functions\MinFunction + || $node instanceof AST\Functions\SumFunction + || $node instanceof AST\AggregateExpression; + } + + private function markAggregateFunctionFound(): void + { + $this->_getQuery()->setHint(self::HINT_HAS_AGGREGATE_FUNCTION, true); + } + + private function wasAggregateFunctionFound(): bool + { + return $this->_getQuery()->hasHint(self::HINT_HAS_AGGREGATE_FUNCTION); + } + +} diff --git a/src/Type/Doctrine/Query/QueryGetDqlDynamicReturnTypeExtension.php b/src/Type/Doctrine/Query/QueryGetDqlDynamicReturnTypeExtension.php index 1c9608b7..d58a5554 100644 --- a/src/Type/Doctrine/Query/QueryGetDqlDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/Query/QueryGetDqlDynamicReturnTypeExtension.php @@ -5,7 +5,6 @@ use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; use PHPStan\Reflection\MethodReflection; -use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\Doctrine\DoctrineTypeUtils; use PHPStan\Type\DynamicMethodReturnTypeExtension; @@ -30,16 +29,12 @@ public function getTypeFromMethodCall( MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope - ): Type + ): ?Type { $calledOnType = $scope->getType($methodCall->var); $queryTypes = DoctrineTypeUtils::getQueryTypes($calledOnType); if (count($queryTypes) === 0) { - return ParametersAcceptorSelector::selectFromArgs( - $scope, - $methodCall->getArgs(), - $methodReflection->getVariants() - )->getReturnType(); + return null; } $dqls = []; diff --git a/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php b/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php index 20e23831..22ed0b5a 100644 --- a/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php @@ -8,16 +8,11 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\ShouldNotHappenException; -use PHPStan\Type\Accessory\AccessoryArrayListType; -use PHPStan\Type\ArrayType; -use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\Doctrine\HydrationModeReturnTypeResolver; +use PHPStan\Type\Doctrine\ObjectMetadataResolver; use PHPStan\Type\DynamicMethodReturnTypeExtension; -use PHPStan\Type\IntegerType; -use PHPStan\Type\IterableType; use PHPStan\Type\NullType; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; -use PHPStan\Type\VoidType; final class QueryResultDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension { @@ -32,6 +27,19 @@ final class QueryResultDynamicReturnTypeExtension implements DynamicMethodReturn 'getSingleResult' => 0, ]; + private ObjectMetadataResolver $objectMetadataResolver; + + private HydrationModeReturnTypeResolver $hydrationModeReturnTypeResolver; + + public function __construct( + ObjectMetadataResolver $objectMetadataResolver, + HydrationModeReturnTypeResolver $hydrationModeReturnTypeResolver + ) + { + $this->objectMetadataResolver = $objectMetadataResolver; + $this->hydrationModeReturnTypeResolver = $hydrationModeReturnTypeResolver; + } + public function getClass(): string { return AbstractQuery::class; @@ -46,7 +54,7 @@ public function getTypeFromMethodCall( MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope - ): Type + ): ?Type { $methodName = $methodReflection->getName(); @@ -60,8 +68,10 @@ public function getTypeFromMethodCall( if (isset($args[$argIndex])) { $hydrationMode = $scope->getType($args[$argIndex]->value); } else { - $parametersAcceptor = ParametersAcceptorSelector::selectSingle( - $methodReflection->getVariants() + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $methodCall->getArgs(), + $methodReflection->getVariants(), ); $parameter = $parametersAcceptor->getParameters()[$argIndex]; $hydrationMode = $parameter->getDefaultValue() ?? new NullType(); @@ -69,81 +79,13 @@ public function getTypeFromMethodCall( $queryType = $scope->getType($methodCall->var); - return $this->getMethodReturnTypeForHydrationMode( - $methodReflection, + return $this->hydrationModeReturnTypeResolver->getMethodReturnTypeForHydrationMode( + $methodReflection->getName(), $hydrationMode, $queryType->getTemplateType(AbstractQuery::class, 'TKey'), - $queryType->getTemplateType(AbstractQuery::class, 'TResult') + $queryType->getTemplateType(AbstractQuery::class, 'TResult'), + $this->objectMetadataResolver->getObjectManager(), ); } - private function getMethodReturnTypeForHydrationMode( - MethodReflection $methodReflection, - Type $hydrationMode, - Type $queryKeyType, - Type $queryResultType - ): Type - { - $isVoidType = (new VoidType())->isSuperTypeOf($queryResultType); - - if ($isVoidType->yes()) { - // A void query result type indicates an UPDATE or DELETE query. - // In this case all methods return the number of affected rows. - return new IntegerType(); - } - - if ($isVoidType->maybe()) { - // We can't be sure what the query type is, so we return the - // declared return type of the method. - return $this->originalReturnType($methodReflection); - } - - if (!$this->isObjectHydrationMode($hydrationMode)) { - // We support only HYDRATE_OBJECT. For other hydration modes, we - // return the declared return type of the method. - return $this->originalReturnType($methodReflection); - } - - switch ($methodReflection->getName()) { - case 'getSingleResult': - return $queryResultType; - case 'getOneOrNullResult': - return TypeCombinator::addNull($queryResultType); - case 'toIterable': - return new IterableType( - $queryKeyType->isNull()->yes() ? new IntegerType() : $queryKeyType, - $queryResultType - ); - default: - if ($queryKeyType->isNull()->yes()) { - return AccessoryArrayListType::intersectWith(new ArrayType( - new IntegerType(), - $queryResultType - )); - } - return new ArrayType( - $queryKeyType, - $queryResultType - ); - } - } - - private function isObjectHydrationMode(Type $type): bool - { - if (!$type instanceof ConstantIntegerType) { - return false; - } - - return $type->getValue() === AbstractQuery::HYDRATE_OBJECT; - } - - private function originalReturnType(MethodReflection $methodReflection): Type - { - $parametersAcceptor = ParametersAcceptorSelector::selectSingle( - $methodReflection->getVariants() - ); - - return $parametersAcceptor->getReturnType(); - } - } diff --git a/src/Type/Doctrine/Query/QueryResultTypeBuilder.php b/src/Type/Doctrine/Query/QueryResultTypeBuilder.php index 30941cb3..e50c75ab 100644 --- a/src/Type/Doctrine/Query/QueryResultTypeBuilder.php +++ b/src/Type/Doctrine/Query/QueryResultTypeBuilder.php @@ -21,15 +21,13 @@ final class QueryResultTypeBuilder { - /** @var bool */ - private $selectQuery = false; + private bool $selectQuery = false; /** * Whether the result is an array shape or a single entity or NEW object * - * @var bool */ - private $isShape = false; + private bool $isShape = false; /** * Map from selected entity aliases to entity types @@ -38,7 +36,7 @@ final class QueryResultTypeBuilder * * @var array */ - private $entities = []; + private array $entities = []; /** * Map from selected entity alias to result alias @@ -47,24 +45,23 @@ final class QueryResultTypeBuilder * * @var array */ - private $entityResultAliases = []; + private array $entityResultAliases = []; /** * Map from selected scalar result alias to scalar type * * @var array */ - private $scalars = []; + private array $scalars = []; /** * Map from selected NEW objcet result alias to NEW object type * * @var array */ - private $newObjects = []; + private array $newObjects = []; - /** @var Type */ - private $indexedBy; + private Type $indexedBy; public function __construct() { @@ -239,7 +236,6 @@ private function resolveOffsetType($alias): Type return new ConstantStringType($alias); } - public function setIndexedBy(Type $type): void { $this->indexedBy = $type; diff --git a/src/Type/Doctrine/Query/QueryResultTypeWalker.php b/src/Type/Doctrine/Query/QueryResultTypeWalker.php index 14519922..d2129f41 100644 --- a/src/Type/Doctrine/Query/QueryResultTypeWalker.php +++ b/src/Type/Doctrine/Query/QueryResultTypeWalker.php @@ -3,7 +3,9 @@ namespace PHPStan\Type\Doctrine\Query; use BackedEnum; -use Doctrine\DBAL\Types\Types; +use Doctrine\DBAL\Types\EnumType as DbalEnumType; +use Doctrine\DBAL\Types\StringType as DbalStringType; +use Doctrine\DBAL\Types\Type as DbalType; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Query; @@ -12,16 +14,25 @@ use Doctrine\ORM\Query\Parser; use Doctrine\ORM\Query\ParserResult; use Doctrine\ORM\Query\SqlWalker; +use PDO; +use PHPStan\Doctrine\Driver\DriverDetector; +use PHPStan\Php\PhpVersion; use PHPStan\ShouldNotHappenException; +use PHPStan\TrinaryLogic; +use PHPStan\Type\Accessory\AccessoryLowercaseStringType; +use PHPStan\Type\Accessory\AccessoryNumericStringType; +use PHPStan\Type\Accessory\AccessoryUppercaseStringType; +use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantFloatType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\ConstantTypeHelper; use PHPStan\Type\Doctrine\DescriptorNotRegisteredException; use PHPStan\Type\Doctrine\DescriptorRegistry; +use PHPStan\Type\Doctrine\Descriptors\DoctrineTypeDriverAwareDescriptor; use PHPStan\Type\FloatType; -use PHPStan\Type\GeneralizePrecision; use PHPStan\Type\IntegerRangeType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; @@ -37,19 +48,24 @@ use PHPStan\Type\UnionType; use function array_key_exists; use function array_map; +use function array_values; use function assert; use function class_exists; use function count; -use function floatval; use function get_class; use function gettype; -use function intval; +use function in_array; +use function is_array; +use function is_int; use function is_numeric; use function is_object; use function is_string; use function serialize; use function sprintf; +use function stripos; +use function strpos; use function strtolower; +use function strtoupper; use function unserialize; /** @@ -66,56 +82,70 @@ class QueryResultTypeWalker extends SqlWalker private const HINT_DESCRIPTOR_REGISTRY = self::class . '::HINT_DESCRIPTOR_REGISTRY'; + private const HINT_PHP_VERSION = self::class . '::HINT_PHP_VERSION'; + + private const HINT_DRIVER_DETECTOR = self::class . '::HINT_DRIVER_DETECTOR'; + /** * Counter for generating unique scalar result. * - * @var int */ - private $scalarResultCounter = 1; + private int $scalarResultCounter = 1; /** * Counter for generating indexes. * - * @var int */ - private $newObjectCounter = 0; + private int $newObjectCounter = 0; /** @var Query */ - private $query; + private Query $query; + + private EntityManagerInterface $em; + + private PhpVersion $phpVersion; - /** @var EntityManagerInterface */ - private $em; + /** @var DriverDetector::*|null */ + private $driverType; + + /** @var array */ + private array $driverOptions; /** * Map of all components/classes that appear in the DQL query. * * @var array $queryComponents */ - private $queryComponents; + private array $queryComponents; /** @var array */ - private $nullableQueryComponents; + private array $nullableQueryComponents; - /** @var QueryResultTypeBuilder */ - private $typeBuilder; + private QueryResultTypeBuilder $typeBuilder; - /** @var DescriptorRegistry */ - private $descriptorRegistry; + private DescriptorRegistry $descriptorRegistry; - /** @var bool */ - private $hasAggregateFunction; + private bool $hasAggregateFunction; - /** @var bool */ - private $hasGroupByClause; + private bool $hasGroupByClause; /** * @param Query $query */ - public static function walk(Query $query, QueryResultTypeBuilder $typeBuilder, DescriptorRegistry $descriptorRegistry): void + public static function walk( + Query $query, + QueryResultTypeBuilder $typeBuilder, + DescriptorRegistry $descriptorRegistry, + PhpVersion $phpVersion, + DriverDetector $driverDetector + ): void { $query->setHint(Query::HINT_CUSTOM_OUTPUT_WALKER, self::class); + $query->setHint(Query::HINT_CUSTOM_TREE_WALKERS, [QueryAggregateFunctionDetectorTreeWalker::class]); $query->setHint(self::HINT_TYPE_MAPPING, $typeBuilder); $query->setHint(self::HINT_DESCRIPTOR_REGISTRY, $descriptorRegistry); + $query->setHint(self::HINT_PHP_VERSION, $phpVersion); + $query->setHint(self::HINT_DRIVER_DETECTOR, $driverDetector); $parser = new Parser($query); $parser->parse(); @@ -134,7 +164,7 @@ public function __construct($query, $parserResult, array $queryComponents) $this->em = $query->getEntityManager(); $this->queryComponents = $queryComponents; $this->nullableQueryComponents = []; - $this->hasAggregateFunction = false; + $this->hasAggregateFunction = $query->hasHint(QueryAggregateFunctionDetectorTreeWalker::HINT_HAS_AGGREGATE_FUNCTION); $this->hasGroupByClause = false; // The object is instantiated by Doctrine\ORM\Query\Parser, so receiving @@ -148,7 +178,7 @@ public function __construct($query, $parserResult, array $queryComponents) 'Expected the query hint %s to contain a %s, but got a %s', self::HINT_TYPE_MAPPING, QueryResultTypeBuilder::class, - is_object($typeBuilder) ? get_class($typeBuilder) : gettype($typeBuilder) + is_object($typeBuilder) ? get_class($typeBuilder) : gettype($typeBuilder), )); } @@ -161,19 +191,46 @@ public function __construct($query, $parserResult, array $queryComponents) 'Expected the query hint %s to contain a %s, but got a %s', self::HINT_DESCRIPTOR_REGISTRY, DescriptorRegistry::class, - is_object($descriptorRegistry) ? get_class($descriptorRegistry) : gettype($descriptorRegistry) + is_object($descriptorRegistry) ? get_class($descriptorRegistry) : gettype($descriptorRegistry), )); } $this->descriptorRegistry = $descriptorRegistry; + $phpVersion = $this->query->getHint(self::HINT_PHP_VERSION); + + if (!$phpVersion instanceof PhpVersion) { // @phpstan-ignore-line ignore bc promise + throw new ShouldNotHappenException(sprintf( + 'Expected the query hint %s to contain a %s, but got a %s', + self::HINT_PHP_VERSION, + PhpVersion::class, + is_object($phpVersion) ? get_class($phpVersion) : gettype($phpVersion), + )); + } + + $this->phpVersion = $phpVersion; + + $driverDetector = $this->query->getHint(self::HINT_DRIVER_DETECTOR); + + if (!$driverDetector instanceof DriverDetector) { + throw new ShouldNotHappenException(sprintf( + 'Expected the query hint %s to contain a %s, but got a %s', + self::HINT_DRIVER_DETECTOR, + DriverDetector::class, + is_object($driverDetector) ? get_class($driverDetector) : gettype($driverDetector), + )); + } + $connection = $this->em->getConnection(); + + $this->driverType = $driverDetector->detect($connection); + $this->driverOptions = $driverDetector->detectDriverOptions($connection); + parent::__construct($query, $parserResult, $queryComponents); } public function walkSelectStatement(AST\SelectStatement $AST): string { $this->typeBuilder->setSelectQuery(); - $this->hasAggregateFunction = $this->hasAggregateFunction($AST); $this->hasGroupByClause = $AST->groupByClause !== null; $this->walkFromClause($AST->fromClause); @@ -223,19 +280,21 @@ public function walkPathExpression($pathExpr): string $dqlAlias = $pathExpr->identificationVariable; $qComp = $this->queryComponents[$dqlAlias]; assert(array_key_exists('metadata', $qComp)); + + /** @var ClassMetadata $class */ $class = $qComp['metadata']; assert($fieldName !== null); switch ($pathExpr->type) { case AST\PathExpression::TYPE_STATE_FIELD: - [$typeName, $enumType] = $this->getTypeOfField($class, $fieldName); + [$typeName, $enumType, $enumValues] = $this->getTypeOfField($class, $fieldName); $nullable = $this->isQueryComponentNullable($dqlAlias) || $class->isNullable($fieldName) || $this->hasAggregateWithoutGroupBy(); - $fieldType = $this->resolveDatabaseInternalType($typeName, $enumType, $nullable); + $fieldType = $this->resolveDatabaseInternalType($typeName, $enumType, $enumValues, $nullable); return $this->marshalType($fieldType); @@ -269,12 +328,12 @@ public function walkPathExpression($pathExpr): string } $targetFieldName = $identifierFieldNames[0]; - [$typeName, $enumType] = $this->getTypeOfField($targetClass, $targetFieldName); + [$typeName, $enumType, $enumValues] = $this->getTypeOfField($targetClass, $targetFieldName); $nullable = ($joinColumn['nullable'] ?? true) || $this->hasAggregateWithoutGroupBy(); - $fieldType = $this->resolveDatabaseInternalType($typeName, $enumType, $nullable); + $fieldType = $this->resolveDatabaseInternalType($typeName, $enumType, $enumValues, $nullable); return $this->marshalType($fieldType); @@ -357,25 +416,61 @@ public function walkFunction($function): string { switch (true) { case $function instanceof AST\Functions\AvgFunction: + return $this->marshalType($this->inferAvgFunction($function)); + case $function instanceof AST\Functions\MaxFunction: case $function instanceof AST\Functions\MinFunction: + // mysql sqlite pdo_pgsql pgsql + // col_float => float float string float + // col_decimal => string int|float string string + // col_int => int int int int + // col_bigint => int int int int + // + // MIN(col_float) => float float string float + // MIN(col_decimal) => string int|float string string + // MIN(col_int) => int int int int + // MIN(col_bigint) => int int int int + + $exprType = $this->unmarshalType($function->getSql($this)); + $exprType = $this->generalizeConstantType($exprType, $this->hasAggregateWithoutGroupBy()); + return $this->marshalType($exprType); // retains underlying type + case $function instanceof AST\Functions\SumFunction: + return $this->marshalType($this->inferSumFunction($function)); + case $function instanceof AST\Functions\CountFunction: - return $function->getSql($this); + return $this->marshalType(IntegerRangeType::fromInterval(0, null)); case $function instanceof AST\Functions\AbsFunction: + // mysql sqlite pdo_pgsql pgsql + // col_float => float float string float + // col_decimal => string int|float string string + // col_int => int int int int + // col_bigint => int int int int + // + // ABS(col_float) => float float string float + // ABS(col_decimal) => string int|float string string + // ABS(col_int) => int int int int + // ABS(col_bigint) => int int int int + // ABS(col_string) => float float x x + $exprType = $this->unmarshalType($this->walkSimpleArithmeticExpression($function->simpleArithmeticExpression)); + $exprType = $this->castStringLiteralForFloatExpression($exprType); + $exprType = $this->generalizeConstantType($exprType, false); - $type = TypeCombinator::union( - IntegerRangeType::fromInterval(0, null), - new FloatType() - ); + $exprTypeNoNull = TypeCombinator::removeNull($exprType); + $nullable = $this->canBeNull($exprType); - if (TypeCombinator::containsNull($exprType)) { - $type = TypeCombinator::addNull($type); + if ($exprTypeNoNull->isInteger()->yes()) { + $nonNegativeInt = $this->createNonNegativeInteger($nullable); + return $this->marshalType($nonNegativeInt); } - return $this->marshalType($type); + if ($this->containsOnlyNumericTypes($exprTypeNoNull)) { + return $this->marshalType($exprType); // retains underlying type + } + + return $this->marshalType(new MixedType()); case $function instanceof AST\Functions\BitAndFunction: case $function instanceof AST\Functions\BitOrFunction: @@ -383,7 +478,7 @@ public function walkFunction($function): string $secondExprType = $this->unmarshalType($function->secondArithmetic->dispatch($this)); $type = IntegerRangeType::fromInterval(0, null); - if (TypeCombinator::containsNull($firstExprType) || TypeCombinator::containsNull($secondExprType)) { + if ($this->canBeNull($firstExprType) || $this->canBeNull($secondExprType)) { $type = TypeCombinator::addNull($type); } @@ -394,7 +489,7 @@ public function walkFunction($function): string foreach ($function->concatExpressions as $expr) { $type = $this->unmarshalType($expr->dispatch($this)); - $hasNull = $hasNull || TypeCombinator::containsNull($type); + $hasNull = $hasNull || $this->canBeNull($type); } $type = new StringType(); @@ -415,7 +510,7 @@ public function walkFunction($function): string $intervalExprType = $this->unmarshalType($function->intervalExpression->dispatch($this)); $type = new StringType(); - if (TypeCombinator::containsNull($dateExprType) || TypeCombinator::containsNull($intervalExprType)) { + if ($this->canBeNull($dateExprType) || $this->canBeNull($intervalExprType)) { $type = TypeCombinator::addNull($type); } @@ -425,11 +520,13 @@ public function walkFunction($function): string $date1ExprType = $this->unmarshalType($function->date1->dispatch($this)); $date2ExprType = $this->unmarshalType($function->date2->dispatch($this)); - $type = TypeCombinator::union( - new IntegerType(), - new FloatType() - ); - if (TypeCombinator::containsNull($date1ExprType) || TypeCombinator::containsNull($date2ExprType)) { + if ($this->driverType === DriverDetector::SQLITE3 || $this->driverType === DriverDetector::PDO_SQLITE) { + $type = new FloatType(); + } else { + $type = new IntegerType(); + } + + if ($this->canBeNull($date1ExprType) || $this->canBeNull($date2ExprType)) { $type = TypeCombinator::addNull($type); } @@ -439,7 +536,7 @@ public function walkFunction($function): string $stringPrimaryType = $this->unmarshalType($function->stringPrimary->dispatch($this)); $type = IntegerRangeType::fromInterval(0, null); - if (TypeCombinator::containsNull($stringPrimaryType)) { + if ($this->canBeNull($stringPrimaryType)) { $type = TypeCombinator::addNull($type); } @@ -450,7 +547,7 @@ public function walkFunction($function): string $secondExprType = $this->unmarshalType($this->walkStringPrimary($function->secondStringPrimary)); $type = IntegerRangeType::fromInterval(0, null); - if (TypeCombinator::containsNull($firstExprType) || TypeCombinator::containsNull($secondExprType)) { + if ($this->canBeNull($firstExprType) || $this->canBeNull($secondExprType)) { $type = TypeCombinator::addNull($type); } @@ -462,7 +559,7 @@ public function walkFunction($function): string $stringPrimaryType = $this->unmarshalType($function->stringPrimary->dispatch($this)); $type = new StringType(); - if (TypeCombinator::containsNull($stringPrimaryType)) { + if ($this->canBeNull($stringPrimaryType)) { $type = TypeCombinator::addNull($type); } @@ -472,23 +569,74 @@ public function walkFunction($function): string $firstExprType = $this->unmarshalType($this->walkSimpleArithmeticExpression($function->firstSimpleArithmeticExpression)); $secondExprType = $this->unmarshalType($this->walkSimpleArithmeticExpression($function->secondSimpleArithmeticExpression)); + $union = TypeCombinator::union($firstExprType, $secondExprType); + $unionNoNull = TypeCombinator::removeNull($union); + + if (!$unionNoNull->isInteger()->yes()) { + return $this->marshalType(new MixedType()); // dont try to deal with non-integer chaos + } + $type = IntegerRangeType::fromInterval(0, null); - if (TypeCombinator::containsNull($firstExprType) || TypeCombinator::containsNull($secondExprType)) { + + if ($this->canBeNull($firstExprType) || $this->canBeNull($secondExprType)) { $type = TypeCombinator::addNull($type); } - if ((new ConstantIntegerType(0))->isSuperTypeOf($secondExprType)->maybe()) { - // MOD(x, 0) returns NULL + $isPgSql = $this->driverType === DriverDetector::PGSQL || $this->driverType === DriverDetector::PDO_PGSQL; + $mayBeZero = !(new ConstantIntegerType(0))->isSuperTypeOf($secondExprType)->no(); + + if (!$isPgSql && $mayBeZero) { // MOD(x, 0) returns NULL in non-strict platforms, fails in postgre $type = TypeCombinator::addNull($type); } - return $this->marshalType($type); + return $this->marshalType($this->generalizeConstantType($type, false)); case $function instanceof AST\Functions\SqrtFunction: + // mysql sqlite pdo_pgsql pgsql + // col_float => float float string float + // col_decimal => string float|int string string + // col_int => int int int int + // col_bigint => int int int int + // + // SQRT(col_float) => float float string float + // SQRT(col_decimal) => float float string string + // SQRT(col_int) => float float string float + // SQRT(col_bigint) => float float string float + $exprType = $this->unmarshalType($this->walkSimpleArithmeticExpression($function->simpleArithmeticExpression)); + $exprTypeNoNull = TypeCombinator::removeNull($exprType); + + if (!$this->containsOnlyNumericTypes($exprTypeNoNull)) { + return $this->marshalType(new MixedType()); // dont try to deal with non-numeric args + } + + if ($this->driverType === DriverDetector::MYSQLI || $this->driverType === DriverDetector::PDO_MYSQL || $this->driverType === DriverDetector::SQLITE3 || $this->driverType === DriverDetector::PDO_SQLITE) { + $type = new FloatType(); + + $cannotBeNegative = $exprType->isSmallerThan(new ConstantIntegerType(0), $this->phpVersion)->no(); + $canBeNegative = !$cannotBeNegative; + if ($canBeNegative) { + $type = TypeCombinator::addNull($type); + } + + } elseif ($this->driverType === DriverDetector::PGSQL || $this->driverType === DriverDetector::PDO_PGSQL) { + $castedExprType = $this->castStringLiteralForNumericExpression($exprTypeNoNull); - $type = new FloatType(); - if (TypeCombinator::containsNull($exprType)) { + if ($castedExprType->isInteger()->yes() || $castedExprType->isFloat()->yes()) { + $type = $this->createFloat(false); + + } elseif ($castedExprType->isNumericString()->yes()) { + $type = $this->createNumericString(false, $castedExprType->isLowercaseString()->yes(), $castedExprType->isUppercaseString()->yes()); + + } else { + $type = TypeCombinator::union($this->createFloat(false), $this->createNumericString(false, false, true)); + } + + } else { + $type = new MixedType(); + } + + if ($this->canBeNull($exprType)) { $type = TypeCombinator::addNull($type); } @@ -505,7 +653,7 @@ public function walkFunction($function): string } $type = new StringType(); - if (TypeCombinator::containsNull($stringType) || TypeCombinator::containsNull($firstExprType) || TypeCombinator::containsNull($secondExprType)) { + if ($this->canBeNull($stringType) || $this->canBeNull($firstExprType) || $this->canBeNull($secondExprType)) { $type = TypeCombinator::addNull($type); } @@ -539,7 +687,7 @@ public function walkFunction($function): string return $this->marshalType(new MixedType()); } - [$typeName, $enumType] = $this->getTypeOfField($targetClass, $targetFieldName); + [$typeName, $enumType, $enumValues] = $this->getTypeOfField($targetClass, $targetFieldName); if (!isset($assoc['joinColumns'])) { return $this->marshalType(new MixedType()); @@ -562,7 +710,7 @@ public function walkFunction($function): string || $this->isQueryComponentNullable($dqlAlias) || $this->hasAggregateWithoutGroupBy(); - $fieldType = $this->resolveDatabaseInternalType($typeName, $enumType, $nullable); + $fieldType = $this->resolveDatabaseInternalType($typeName, $enumType, $enumValues, $nullable); return $this->marshalType($fieldType); @@ -571,6 +719,228 @@ public function walkFunction($function): string } } + private function inferAvgFunction(AST\Functions\AvgFunction $function): Type + { + // mysql sqlite pdo_pgsql pgsql + // col_float => float float string float + // col_decimal => string int|float string string + // col_int => int int int int + // col_bigint => int int int int + // + // AVG(col_float) => float float string float + // AVG(col_decimal) => string float string string + // AVG(col_int) => string float string string + // AVG(col_bigint) => string float string string + + $exprType = $this->unmarshalType($function->getSql($this)); + $exprTypeNoNull = TypeCombinator::removeNull($exprType); + $nullable = $this->canBeNull($exprType) || $this->hasAggregateWithoutGroupBy(); + + if ($this->driverType === DriverDetector::SQLITE3 || $this->driverType === DriverDetector::PDO_SQLITE) { + return $this->createFloat($nullable); + } + + if ($this->driverType === DriverDetector::PDO_MYSQL || $this->driverType === DriverDetector::MYSQLI) { + if ($exprTypeNoNull->isInteger()->yes()) { + return $this->createNumericString($nullable, true, true); + } + + if ($exprTypeNoNull->isString()->yes() && !$exprTypeNoNull->isNumericString()->yes()) { + return $this->createFloat($nullable); + } + + return $this->generalizeConstantType($exprType, $nullable); + } + + if ($this->driverType === DriverDetector::PGSQL || $this->driverType === DriverDetector::PDO_PGSQL) { + if ($exprTypeNoNull->isInteger()->yes()) { + return $this->createNumericString($nullable, true, true); + } + + return $this->generalizeConstantType($exprType, $nullable); + } + + return new MixedType(); + } + + private function inferSumFunction(AST\Functions\SumFunction $function): Type + { + // mysql sqlite pdo_pgsql pgsql + // col_float => float float string float + // col_decimal => string int|float string string + // col_int => int int int int + // col_bigint => int int int int + // + // SUM(col_float) => float float string float + // SUM(col_decimal) => string int|float string string + // SUM(col_int) => string int int int + // SUM(col_bigint) => string int string string + + $exprType = $this->unmarshalType($function->getSql($this)); + $exprTypeNoNull = TypeCombinator::removeNull($exprType); + $nullable = $this->canBeNull($exprType) || $this->hasAggregateWithoutGroupBy(); + + if ($this->driverType === DriverDetector::SQLITE3 || $this->driverType === DriverDetector::PDO_SQLITE) { + if ($exprTypeNoNull->isString()->yes() && !$exprTypeNoNull->isNumericString()->yes()) { + return $this->createFloat($nullable); + } + + return $this->generalizeConstantType($exprType, $nullable); + } + + if ($this->driverType === DriverDetector::PDO_MYSQL || $this->driverType === DriverDetector::MYSQLI) { + if ($exprTypeNoNull->isInteger()->yes()) { + return $this->createNumericString($nullable, true, true); + } + + if ($exprTypeNoNull->isString()->yes() && !$exprTypeNoNull->isNumericString()->yes()) { + return $this->createFloat($nullable); + } + + return $this->generalizeConstantType($exprType, $nullable); + } + + if ($this->driverType === DriverDetector::PGSQL || $this->driverType === DriverDetector::PDO_PGSQL) { + if ($exprTypeNoNull->isInteger()->yes()) { + return TypeCombinator::union( + $this->createInteger($nullable), + $this->createNumericString($nullable, true, true), + ); + } + + return $this->generalizeConstantType($exprType, $nullable); + } + + return new MixedType(); + } + + private function createFloat(bool $nullable): Type + { + $float = new FloatType(); + return $nullable ? TypeCombinator::addNull($float) : $float; + } + + private function createFloatOrInt(bool $nullable): Type + { + $union = TypeCombinator::union( + new FloatType(), + new IntegerType(), + ); + return $nullable ? TypeCombinator::addNull($union) : $union; + } + + private function createInteger(bool $nullable): Type + { + $integer = new IntegerType(); + return $nullable ? TypeCombinator::addNull($integer) : $integer; + } + + private function createNonNegativeInteger(bool $nullable): Type + { + $integer = IntegerRangeType::fromInterval(0, null); + return $nullable ? TypeCombinator::addNull($integer) : $integer; + } + + private function createNumericString(bool $nullable, bool $lowercase = false, bool $uppercase = false): Type + { + $types = [ + new StringType(), + new AccessoryNumericStringType(), + ]; + if ($lowercase) { + $types[] = new AccessoryLowercaseStringType(); + } + if ($uppercase) { + $types[] = new AccessoryUppercaseStringType(); + } + + $numericString = new IntersectionType($types); + + return $nullable ? TypeCombinator::addNull($numericString) : $numericString; + } + + private function createString(bool $nullable, bool $lowercase = false, bool $uppercase = false): Type + { + if ($lowercase || $uppercase) { + $types = [ + new StringType(), + ]; + if ($lowercase) { + $types[] = new AccessoryLowercaseStringType(); + } + if ($uppercase) { + $types[] = new AccessoryUppercaseStringType(); + } + $string = new IntersectionType($types); + } else { + $string = new StringType(); + } + + return $nullable ? TypeCombinator::addNull($string) : $string; + } + + private function containsOnlyNumericTypes( + Type ...$checkedTypes + ): bool + { + foreach ($checkedTypes as $checkedType) { + if (!$this->containsOnlyTypes($checkedType, [new IntegerType(), new FloatType(), $this->createNumericString(false)])) { + return false; + } + } + return true; + } + + /** + * @param list $allowedTypes + */ + private function containsOnlyTypes( + Type $checkedType, + array $allowedTypes + ): bool + { + $allowedType = TypeCombinator::union(...$allowedTypes); + return $allowedType->isSuperTypeOf($checkedType)->yes(); + } + + /** + * E.g. to ensure SUM(1) is inferred as int, not 1 + */ + private function generalizeConstantType(Type $type, bool $makeNullable): Type + { + $containsNull = $this->canBeNull($type); + $typeNoNull = TypeCombinator::removeNull($type); + + if (!$typeNoNull->isConstantScalarValue()->yes()) { + $result = $type; + + } elseif ($typeNoNull->isInteger()->yes()) { + $result = $this->createInteger($containsNull); + + } elseif ($typeNoNull->isFloat()->yes()) { + $result = $this->createFloat($containsNull); + + } elseif ($typeNoNull->isNumericString()->yes()) { + $result = $this->createNumericString( + $containsNull, + $typeNoNull->isLowercaseString()->yes(), + $typeNoNull->isUppercaseString()->yes(), + ); + + } elseif ($typeNoNull->isString()->yes()) { + $result = $this->createString( + $containsNull, + $typeNoNull->isLowercaseString()->yes(), + $typeNoNull->isUppercaseString()->yes(), + ); + + } else { + $result = $type; + } + + return $makeNullable ? TypeCombinator::addNull($result) : $result; + } + /** * @param AST\OrderByClause $orderByClause */ @@ -626,6 +996,7 @@ public function walkJoin($join): string */ public function walkCoalesceExpression($coalesceExpression): string { + $rawTypes = []; $expressionTypes = []; $allTypesContainNull = true; @@ -635,19 +1006,67 @@ public function walkCoalesceExpression($coalesceExpression): string continue; } - $type = $this->unmarshalType($expression->dispatch($this)); - $allTypesContainNull = $allTypesContainNull && TypeCombinator::containsNull($type); + $rawType = $this->unmarshalType($expression->dispatch($this)); + $rawTypes[] = $rawType; + + $allTypesContainNull = $allTypesContainNull && $this->canBeNull($rawType); - $expressionTypes[] = $type; + // Some drivers manipulate the types, lets avoid false positives by generalizing constant types + // e.g. sqlsrv: "COALESCE returns the data type of value with the highest precedence" + // e.g. mysql: COALESCE(1, 'foo') === '1' (undocumented? https://gist.github.com/jrunning/4535434) + $expressionTypes[] = $this->generalizeConstantType($rawType, false); } - $type = TypeCombinator::union(...$expressionTypes); + $generalizedUnion = TypeCombinator::union(...$expressionTypes); if (!$allTypesContainNull) { - $type = TypeCombinator::removeNull($type); + $generalizedUnion = TypeCombinator::removeNull($generalizedUnion); } - return $this->marshalType($type); + if ($this->driverType === DriverDetector::MYSQLI || $this->driverType === DriverDetector::PDO_MYSQL) { + return $this->marshalType( + $this->inferCoalesceForMySql($rawTypes, $generalizedUnion), + ); + } + + return $this->marshalType($generalizedUnion); + } + + /** + * @param list $rawTypes + */ + private function inferCoalesceForMySql(array $rawTypes, Type $originalResult): Type + { + $containsString = false; + $containsFloat = false; + $allIsNumericExcludingLiteralString = true; + + foreach ($rawTypes as $rawType) { + $rawTypeNoNull = TypeCombinator::removeNull($rawType); + $isLiteralString = $rawTypeNoNull instanceof DqlConstantStringType && $rawTypeNoNull->getOriginLiteralType() === AST\Literal::STRING; + + if (!$this->containsOnlyNumericTypes($rawTypeNoNull) || $isLiteralString) { + $allIsNumericExcludingLiteralString = false; + } + + if ($rawTypeNoNull->isString()->yes()) { + $containsString = true; + } + + if (!$rawTypeNoNull->isFloat()->yes()) { + continue; + } + + $containsFloat = true; + } + + if ($containsFloat && $allIsNumericExcludingLiteralString) { + return $this->simpleFloatify($originalResult); + } elseif ($containsString) { + return $this->simpleStringify($originalResult); + } + + return $originalResult; } /** @@ -688,13 +1107,13 @@ public function walkGeneralCaseExpression(AST\GeneralCaseExpression $generalCase } $types[] = $this->unmarshalType( - $thenScalarExpression->dispatch($this) + $thenScalarExpression->dispatch($this), ); } if ($elseScalarExpression instanceof AST\Node) { $types[] = $this->unmarshalType( - $elseScalarExpression->dispatch($this) + $elseScalarExpression->dispatch($this), ); } @@ -725,13 +1144,13 @@ public function walkSimpleCaseExpression($simpleCaseExpression): string } $types[] = $this->unmarshalType( - $thenScalarExpression->dispatch($this) + $thenScalarExpression->dispatch($this), ); } if ($elseScalarExpression instanceof AST\Node) { $types[] = $this->unmarshalType( - $elseScalarExpression->dispatch($this) + $elseScalarExpression->dispatch($this), ); } @@ -789,13 +1208,13 @@ public function walkSelectExpression($selectExpression): string assert(array_key_exists('metadata', $qComp)); $class = $qComp['metadata']; - [$typeName, $enumType] = $this->getTypeOfField($class, $fieldName); + [$typeName, $enumType, $enumValues] = $this->getTypeOfField($class, $fieldName); $nullable = $this->isQueryComponentNullable($dqlAlias) || $class->isNullable($fieldName) || $this->hasAggregateWithoutGroupBy(); - $type = $this->resolveDoctrineType($typeName, $enumType, $nullable); + $type = $this->resolveDoctrineType($typeName, $enumType, $enumValues, $nullable); $this->typeBuilder->addScalar($resultAlias, $type); @@ -815,46 +1234,76 @@ public function walkSelectExpression($selectExpression): string $resultAlias = $selectExpression->fieldIdentificationVariable ?? $this->scalarResultCounter++; $type = $this->unmarshalType($expr->dispatch($this)); - if (class_exists(TypedExpression::class) && $expr instanceof TypedExpression) { - $enforcedType = $this->resolveDoctrineType(Types::INTEGER); - $type = TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($enforcedType): Type { - if ($type instanceof UnionType || $type instanceof IntersectionType) { - return $traverse($type); - } - if ($type instanceof NullType) { - return $type; - } - if ($enforcedType->accepts($type, true)->yes()) { - return $type; - } - if ($enforcedType instanceof StringType) { - if ($type instanceof IntegerType || $type instanceof FloatType) { - return TypeCombinator::union($type->toString(), $type); - } - if ($type instanceof BooleanType) { - return TypeCombinator::union($type->toInteger()->toString(), $type); - } - } - return $enforcedType; - }); + if ( + $expr instanceof TypedExpression + && !$expr->getReturnType() instanceof DbalStringType // StringType is no-op, so using TypedExpression with that does nothing + && !$expr->getReturnType() instanceof DbalEnumType // EnumType is also no-op + ) { + $dbalTypeName = DbalType::getTypeRegistry()->lookupName($expr->getReturnType()); + $type = TypeCombinator::intersect( // e.g. count is typed as int, but we infer int<0, max> + $type, + $this->resolveDoctrineType($dbalTypeName, null, null, TypeCombinator::containsNull($type)), + ); + + if ($this->hasAggregateWithoutGroupBy() && !$expr instanceof AST\Functions\CountFunction) { + $type = TypeCombinator::addNull($type); + } + } else { // Expressions default to Doctrine's StringType, whose // convertToPHPValue() is a no-op. So the actual type depends on // the driver and PHP version. - // Here we assume that the value may or may not be casted to - // string by the driver. - $type = TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { + + $type = TypeTraverser::map($type, function (Type $type, callable $traverse): Type { if ($type instanceof UnionType || $type instanceof IntersectionType) { return $traverse($type); } - if ($type instanceof IntegerType || $type instanceof FloatType) { - return TypeCombinator::union($type->toString(), $type); + + if ($type instanceof IntegerType) { + $stringify = $this->shouldStringifyExpressions($type); + + if ($stringify->yes()) { + return $type->toString(); + } elseif ($stringify->maybe()) { + return TypeCombinator::union($type->toString(), $type); + } + + return $type; + } + + if ($type instanceof FloatType) { + $stringify = $this->shouldStringifyExpressions($type); + + // e.g. 1.0 on sqlite results to '1' with pdo_stringify on PHP 8.1, but '1.0' on PHP 8.0 with no setup + // so we relax constant types and return just numeric-string to avoid those issues + $stringifiedFloat = $this->createNumericString(false, false, true); + + if ($stringify->yes()) { + return $stringifiedFloat; + } elseif ($stringify->maybe()) { + return TypeCombinator::union($stringifiedFloat, $type); + } + + return $type; } + if ($type instanceof BooleanType) { - return TypeCombinator::union($type->toInteger()->toString(), $type); + $stringify = $this->shouldStringifyExpressions($type); + + if ($stringify->yes()) { + return $type->toInteger()->toString(); + } elseif ($stringify->maybe()) { + return TypeCombinator::union($type->toInteger()->toString(), $type); + } + + return $type; } return $traverse($type); }); + + if (!$this->isSupportedDriver()) { + $type = new MixedType(); // avoid guessing for unsupported drivers, there are too many differences + } } $this->typeBuilder->addScalar($resultAlias, $type); @@ -930,40 +1379,63 @@ public function walkSimpleSelectExpression($simpleSelectExpression): string */ public function walkAggregateExpression($aggExpression): string { - switch ($aggExpression->functionName) { + switch (strtoupper($aggExpression->functionName)) { + case 'AVG': + case 'SUM': + $type = $this->unmarshalType($this->walkSimpleArithmeticExpression($aggExpression->pathExpression)); + $type = $this->castStringLiteralForNumericExpression($type); + return $this->marshalType($type); + case 'MAX': case 'MIN': - $type = $this->unmarshalType( - $this->walkSimpleArithmeticExpression($aggExpression->pathExpression) - ); + return $this->walkSimpleArithmeticExpression($aggExpression->pathExpression); - return $this->marshalType(TypeCombinator::addNull($type)); + case 'COUNT': + return $this->marshalType(IntegerRangeType::fromInterval(0, null)); - case 'AVG': - $type = $this->unmarshalType( - $this->walkSimpleArithmeticExpression($aggExpression->pathExpression) - ); + default: + return $this->marshalType(new MixedType()); + } + } - $type = TypeCombinator::union($type, $type->toFloat()); - $type = TypeUtils::generalizeType($type, GeneralizePrecision::lessSpecific()); + private function castStringLiteralForFloatExpression(Type $type): Type + { + if (!$type instanceof DqlConstantStringType || $type->getOriginLiteralType() !== AST\Literal::STRING) { + return $type; + } - return $this->marshalType(TypeCombinator::addNull($type)); + $value = $type->getValue(); - case 'SUM': - $type = $this->unmarshalType( - $this->walkSimpleArithmeticExpression($aggExpression->pathExpression) - ); + if (is_numeric($value)) { + return new ConstantFloatType((float) $value); + } - $type = TypeUtils::generalizeType($type, GeneralizePrecision::lessSpecific()); + return $type; + } - return $this->marshalType(TypeCombinator::addNull($type)); + /** + * Numeric strings are kept as strings in literal usage, but casted to numeric value once used in numeric expression + * - SELECT '1' => '1' + * - SELECT 1 * '1' => 1 + */ + private function castStringLiteralForNumericExpression(Type $type): Type + { + if (!$type instanceof DqlConstantStringType || $type->getOriginLiteralType() !== AST\Literal::STRING) { + return $type; + } - case 'COUNT': - return $this->marshalType(IntegerRangeType::fromInterval(0, null)); + $isMysql = $this->driverType === DriverDetector::MYSQLI || $this->driverType === DriverDetector::PDO_MYSQL; + $value = $type->getValue(); - default: - return $this->marshalType(new MixedType()); + if (is_numeric($value)) { + if (strpos($value, '.') === false && strpos($value, 'e') === false && !$isMysql) { + return new ConstantIntegerType((int) $value); + } + + return new ConstantFloatType((float) $value); } + + return $type; } /** @@ -1108,22 +1580,46 @@ public function walkLiteral($literal): string case AST\Literal::STRING: $value = $literal->value; assert(is_string($value)); - $type = new ConstantStringType($value); + $type = new DqlConstantStringType($value, $literal->type); break; case AST\Literal::BOOLEAN: - $value = strtolower($literal->value) === 'true' ? 1 : 0; - $type = new ConstantIntegerType($value); + $value = strtolower($literal->value) === 'true'; + if ($this->driverType === DriverDetector::PDO_PGSQL || $this->driverType === DriverDetector::PGSQL) { + $type = new ConstantBooleanType($value); + } else { + $type = new ConstantIntegerType($value ? 1 : 0); + } break; case AST\Literal::NUMERIC: $value = $literal->value; - assert(is_numeric($value)); + assert(is_int($value) || is_string($value)); // ensured in parser - if (floatval(intval($value)) === floatval($value)) { + if (is_int($value) || (strpos($value, '.') === false && strpos($value, 'e') === false)) { $type = new ConstantIntegerType((int) $value); + } else { - $type = new ConstantFloatType((float) $value); + if ($this->driverType === DriverDetector::PDO_MYSQL || $this->driverType === DriverDetector::MYSQLI) { + // both pdo_mysql and mysqli hydrates decimal literal (e.g. 123.4) as string no matter the configuration (e.g. PDO::ATTR_STRINGIFY_FETCHES being false) and PHP version + // the only way to force float is to use float literal with scientific notation (e.g. 123.4e0) + // https://dev.mysql.com/doc/refman/8.0/en/number-literals.html + + if (stripos($value, 'e') !== false) { + $type = new ConstantFloatType((float) $value); + } else { + $type = new DqlConstantStringType($value, $literal->type); + } + } elseif ($this->driverType === DriverDetector::PGSQL || $this->driverType === DriverDetector::PDO_PGSQL) { + if (stripos($value, 'e') !== false) { + $type = new DqlConstantStringType((string) (float) $value, $literal->type); + } else { + $type = new DqlConstantStringType($value, $literal->type); + } + + } else { + $type = new ConstantFloatType((float) $value); + } } break; @@ -1208,14 +1704,13 @@ public function walkSimpleArithmeticExpression($simpleArithmeticExpr): string // Skip '+' or '-' continue; } - $type = $this->unmarshalType($this->walkArithmeticPrimary($term)); - $types[] = TypeUtils::generalizeType($type, GeneralizePrecision::lessSpecific()); - } - $type = TypeCombinator::union(...$types); - $type = $this->toNumericOrNull($type); + $types[] = $this->castStringLiteralForNumericExpression( + $this->unmarshalType($this->walkArithmeticPrimary($term)), + ); + } - return $this->marshalType($type); + return $this->marshalType($this->inferPlusMinusTimesType($types)); } /** @@ -1228,20 +1723,192 @@ public function walkArithmeticTerm($term): string } $types = []; + $operators = []; foreach ($term->arithmeticFactors as $factor) { if (!$factor instanceof AST\Node) { - // Skip '*' or '/' - continue; + assert(is_string($factor)); + $operators[$factor] = $factor; + continue; // Skip '*' or '/' } - $type = $this->unmarshalType($this->walkArithmeticPrimary($factor)); - $types[] = TypeUtils::generalizeType($type, GeneralizePrecision::lessSpecific()); + + $types[] = $this->castStringLiteralForNumericExpression( + $this->unmarshalType($this->walkArithmeticPrimary($factor)), + ); } - $type = TypeCombinator::union(...$types); - $type = $this->toNumericOrNull($type); + if (array_values($operators) === ['*']) { + return $this->marshalType($this->inferPlusMinusTimesType($types)); + } - return $this->marshalType($type); + return $this->marshalType($this->inferDivisionType($types)); + } + + /** + * @param list $termTypes + */ + private function inferPlusMinusTimesType(array $termTypes): Type + { + // mysql sqlite pdo_pgsql pgsql + // col_float float float string float + // col_decimal string float|int string string + // col_int int int int int + // col_bigint int int int int + // col_bool int int bool bool + // + // col_int + col_int int int int int + // col_int + col_float float float string float + // col_float + col_float float float string float + // col_float + col_decimal float float string float + // col_int + col_decimal string float|int string string + // col_decimal + col_decimal string float|int string string + // col_string + col_string float int x x + // col_int + col_string float int x x + // col_bool + col_bool int int x x + // col_int + col_bool int int x x + // col_float + col_string float float x x + // col_decimal + col_string float float|int x x + // col_float + col_bool float float x x + // col_decimal + col_bool string float|int x x + + $types = []; + $typesNoNull = []; + + foreach ($termTypes as $termType) { + $generalizedType = $this->generalizeConstantType($termType, false); + $types[] = $generalizedType; + $typesNoNull[] = TypeCombinator::removeNull($generalizedType); + } + + $union = TypeCombinator::union(...$types); + $nullable = $this->canBeNull($union); + $unionWithoutNull = TypeCombinator::removeNull($union); + + if ($unionWithoutNull->isInteger()->yes()) { + return $this->createInteger($nullable); + } + + if ($this->driverType === DriverDetector::SQLITE3 || $this->driverType === DriverDetector::PDO_SQLITE) { + if (!$this->containsOnlyNumericTypes(...$typesNoNull)) { + return new MixedType(); + } + + foreach ($typesNoNull as $typeNoNull) { + if ($typeNoNull->isFloat()->yes()) { + return $this->createFloat($nullable); + } + } + + return $this->createFloatOrInt($nullable); + } + + if ($this->driverType === DriverDetector::MYSQLI || $this->driverType === DriverDetector::PDO_MYSQL || $this->driverType === DriverDetector::PGSQL || $this->driverType === DriverDetector::PDO_PGSQL) { + if ($this->containsOnlyTypes($unionWithoutNull, [new IntegerType(), new FloatType()])) { + return $this->createFloat($nullable); + } + + if ($this->containsOnlyTypes($unionWithoutNull, [new IntegerType(), $this->createNumericString(false)])) { + return $this->createNumericString( + $nullable, + $unionWithoutNull->toString()->isLowercaseString()->yes(), + $unionWithoutNull->toString()->isUppercaseString()->yes(), + ); + } + + if ($this->containsOnlyNumericTypes($unionWithoutNull)) { + return $this->createFloat($nullable); + } + } + + return new MixedType(); + } + + /** + * @param list $termTypes + */ + private function inferDivisionType(array $termTypes): Type + { + // mysql sqlite pdo_pgsql pgsql + // col_float => float float string float + // col_decimal => string float|int string string + // col_int => int int int int + // col_bigint => int int int int + // + // col_int / col_int string int int int + // col_int / col_float float float string float + // col_float / col_float float float string float + // col_float / col_decimal float float string float + // col_int / col_decimal string float|int string string + // col_decimal / col_decimal string float|int string string + // col_string / col_string null null x x + // col_int / col_string null null x x + // col_bool / col_bool string int x x + // col_int / col_bool string int x x + // col_float / col_string null null x x + // col_decimal / col_string null null x x + // col_float / col_bool float float x x + // col_decimal / col_bool string float x x + + $types = []; + $typesNoNull = []; + + foreach ($termTypes as $termType) { + $generalizedType = $this->generalizeConstantType($termType, false); + $types[] = $generalizedType; + $typesNoNull[] = TypeCombinator::removeNull($generalizedType); + } + + $union = TypeCombinator::union(...$types); + $nullable = $this->canBeNull($union); + $unionWithoutNull = TypeCombinator::removeNull($union); + + if ($unionWithoutNull->isInteger()->yes()) { + if ($this->driverType === DriverDetector::MYSQLI || $this->driverType === DriverDetector::PDO_MYSQL) { + return $this->createNumericString($nullable, true, true); + } elseif ($this->driverType === DriverDetector::PDO_PGSQL || $this->driverType === DriverDetector::PGSQL || $this->driverType === DriverDetector::SQLITE3 || $this->driverType === DriverDetector::PDO_SQLITE) { + return $this->createInteger($nullable); + } + + return new MixedType(); + } + + if ($this->driverType === DriverDetector::SQLITE3 || $this->driverType === DriverDetector::PDO_SQLITE) { + if (!$this->containsOnlyNumericTypes(...$typesNoNull)) { + return new MixedType(); + } + + foreach ($typesNoNull as $typeNoNull) { + if ($typeNoNull->isFloat()->yes()) { + return $this->createFloat($nullable); + } + } + + return $this->createFloatOrInt($nullable); + } + + if ($this->driverType === DriverDetector::MYSQLI || $this->driverType === DriverDetector::PDO_MYSQL || $this->driverType === DriverDetector::PGSQL || $this->driverType === DriverDetector::PDO_PGSQL) { + if ($this->containsOnlyTypes($unionWithoutNull, [new IntegerType(), new FloatType()])) { + return $this->createFloat($nullable); + } + + if ($this->containsOnlyTypes($unionWithoutNull, [new IntegerType(), $this->createNumericString(false)])) { + return $this->createNumericString( + $nullable, + $unionWithoutNull->toString()->isLowercaseString()->yes(), + $unionWithoutNull->toString()->isUppercaseString()->yes(), + ); + } + + if ($this->containsOnlyTypes($unionWithoutNull, [new FloatType(), $this->createNumericString(false)])) { + return $this->createFloat($nullable); + } + + if ($this->containsOnlyNumericTypes($unionWithoutNull)) { + return $this->createFloat($nullable); + } + } + + return new MixedType(); } /** @@ -1256,7 +1923,19 @@ public function walkArithmeticFactor($factor): string $primary = $factor->arithmeticPrimary; $type = $this->unmarshalType($this->walkArithmeticPrimary($primary)); - $type = TypeUtils::generalizeType($type, GeneralizePrecision::lessSpecific()); + + if ($type instanceof ConstantIntegerType && $factor->sign === false) { + $type = new ConstantIntegerType($type->getValue() * -1); + + } elseif ($type instanceof IntegerRangeType && $factor->sign === false) { + $type = IntegerRangeType::fromInterval( + $type->getMax() === null ? null : $type->getMax() * -1, + $type->getMin() === null ? null : $type->getMin() * -1, + ); + + } elseif ($type instanceof ConstantFloatType && $factor->sign === false) { + $type = new ConstantFloatType($type->getValue() * -1); + } return $this->marshalType($type); } @@ -1321,7 +2000,7 @@ private function isQueryComponentNullable(string $dqlAlias): bool /** * @param ClassMetadata $class - * @return array{string, ?class-string} Doctrine type name and enum type of field + * @return array{string, ?class-string, ?list} Doctrine type name, enum type of field, enum values */ private function getTypeOfField(ClassMetadata $class, string $fieldName): array { @@ -1339,23 +2018,74 @@ private function getTypeOfField(ClassMetadata $class, string $fieldName): array $enumType = null; } - return [$type, $enumType]; + return [$type, $enumType, $this->detectEnumValues($type, $metadata)]; } - /** @param ?class-string $enumType */ - private function resolveDoctrineType(string $typeName, ?string $enumType = null, bool $nullable = false): Type + /** + * @param mixed $metadata + * + * @return list|null + */ + private function detectEnumValues(string $typeName, $metadata): ?array { - if ($enumType !== null) { - $type = new ObjectType($enumType); - } else { - try { - $type = $this->descriptorRegistry - ->get($typeName) - ->getWritableToPropertyType(); - if ($type instanceof NeverType) { - $type = new MixedType(); + if ($typeName !== 'enum') { + return null; + } + + $values = $metadata['options']['values'] ?? []; + + if (!is_array($values) || count($values) === 0) { + return null; + } + + foreach ($values as $value) { + if (!is_string($value)) { + return null; + } + } + + return array_values($values); + } + + /** + * @param ?class-string $enumType + * @param ?list $enumValues + */ + private function resolveDoctrineType( + string $typeName, + ?string $enumType = null, + ?array $enumValues = null, + bool $nullable = false + ): Type + { + try { + $type = $this->descriptorRegistry + ->get($typeName) + ->getWritableToPropertyType(); + + if ($enumType !== null) { + if ($type->isArray()->no()) { + $type = new ObjectType($enumType); + } else { + $type = TypeCombinator::intersect(new ArrayType( + $type->getIterableKeyType(), + new ObjectType($enumType), + ), ...TypeUtils::getAccessoryTypes($type)); } - } catch (DescriptorNotRegisteredException $e) { + } + + if ($enumValues !== null) { + $enumValuesType = TypeCombinator::union(...array_map(static fn (string $value) => new ConstantStringType($value), $enumValues)); + $type = TypeCombinator::intersect($enumValuesType, $type); + } + + if ($type instanceof NeverType) { + $type = new MixedType(); + } + } catch (DescriptorNotRegisteredException $e) { + if ($enumType !== null) { + $type = new ObjectType($enumType); + } else { $type = new MixedType(); } } @@ -1367,26 +2097,39 @@ private function resolveDoctrineType(string $typeName, ?string $enumType = null, return $type; } - /** @param ?class-string $enumType */ - private function resolveDatabaseInternalType(string $typeName, ?string $enumType = null, bool $nullable = false): Type + /** + * @param ?class-string $enumType + * @param ?list $enumValues + */ + private function resolveDatabaseInternalType( + string $typeName, + ?string $enumType = null, + ?array $enumValues = null, + bool $nullable = false + ): Type { try { - $type = $this->descriptorRegistry - ->get($typeName) - ->getDatabaseInternalType(); + $descriptor = $this->descriptorRegistry->get($typeName); + $type = $descriptor instanceof DoctrineTypeDriverAwareDescriptor + ? $descriptor->getDatabaseInternalTypeForDriver($this->em->getConnection()) + : $descriptor->getDatabaseInternalType(); + } catch (DescriptorNotRegisteredException $e) { $type = new MixedType(); } if ($enumType !== null) { - $enumTypes = array_map(static function ($enumType) { - return ConstantTypeHelper::getTypeFromValue($enumType->value); - }, $enumType::cases()); + $enumTypes = array_map(static fn ($enumType) => ConstantTypeHelper::getTypeFromValue($enumType->value), $enumType::cases()); $enumType = TypeCombinator::union(...$enumTypes); $enumType = TypeCombinator::union($enumType, $enumType->toString()); $type = TypeCombinator::intersect($enumType, $type); } + if ($enumValues !== null) { + $enumValuesType = TypeCombinator::union(...array_map(static fn (string $value) => new ConstantStringType($value), $enumValues)); + $type = TypeCombinator::intersect($enumValuesType, $type); + } + if ($nullable) { $type = TypeCombinator::addNull($type); } @@ -1394,23 +2137,9 @@ private function resolveDatabaseInternalType(string $typeName, ?string $enumType return $type; } - private function toNumericOrNull(Type $type): Type + private function canBeNull(Type $type): bool { - return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { - if ($type instanceof UnionType || $type instanceof IntersectionType) { - return $traverse($type); - } - if ($type instanceof NullType || $type instanceof IntegerType) { - return $type; - } - if ($type instanceof BooleanType) { - return $type->toInteger(); - } - return TypeCombinator::union( - $type->toFloat(), - $type->toInteger() - ); - }); + return !$type->isSuperTypeOf(new NullType())->no(); } /** @@ -1427,29 +2156,122 @@ private function hasAggregateWithoutGroupBy(): bool return $this->hasAggregateFunction && !$this->hasGroupByClause; } - private function hasAggregateFunction(AST\SelectStatement $AST): bool + /** + * See analysis: https://github.com/janedbal/php-database-drivers-fetch-test + * + * Notable 8.1 changes: + * - pdo_mysql: https://github.com/php/php-src/commit/c18b1aea289e8ed6edb3f6e6a135018976a034c6 + * - pdo_sqlite: https://github.com/php/php-src/commit/438b025a28cda2935613af412fc13702883dd3a2 + * - pdo_pgsql: https://github.com/php/php-src/commit/737195c3ae6ac53b9501cfc39cc80fd462909c82 + * + * Notable 8.4 changes: + * - pdo_pgsql: https://github.com/php/php-src/commit/6d10a6989897e9089d62edf939344437128e93ad + * + * @param IntegerType|FloatType|BooleanType $type + */ + private function shouldStringifyExpressions(Type $type): TrinaryLogic { - foreach ($AST->selectClause->selectExpressions as $selectExpression) { - if (!$selectExpression instanceof AST\SelectExpression) { - continue; + if (in_array($this->driverType, [DriverDetector::PDO_MYSQL, DriverDetector::PDO_PGSQL, DriverDetector::PDO_SQLITE], true)) { + $stringifyFetches = isset($this->driverOptions[PDO::ATTR_STRINGIFY_FETCHES]) ? (bool) $this->driverOptions[PDO::ATTR_STRINGIFY_FETCHES] : false; + + if ($this->driverType === DriverDetector::PDO_MYSQL) { + $emulatedPrepares = isset($this->driverOptions[PDO::ATTR_EMULATE_PREPARES]) ? (bool) $this->driverOptions[PDO::ATTR_EMULATE_PREPARES] : true; + + if ($stringifyFetches) { + return TrinaryLogic::createYes(); + } + + if ($this->phpVersion->getVersionId() >= 80100) { + return TrinaryLogic::createNo(); + } + + if ($emulatedPrepares) { + return TrinaryLogic::createYes(); + } + + return TrinaryLogic::createNo(); } - $expression = $selectExpression->expression; - - switch (true) { - case $expression instanceof AST\Functions\AvgFunction: - case $expression instanceof AST\Functions\CountFunction: - case $expression instanceof AST\Functions\MaxFunction: - case $expression instanceof AST\Functions\MinFunction: - case $expression instanceof AST\Functions\SumFunction: - case $expression instanceof AST\AggregateExpression: - return true; - default: - break; + if ($this->driverType === DriverDetector::PDO_SQLITE) { + if ($stringifyFetches) { + return TrinaryLogic::createYes(); + } + + if ($this->phpVersion->getVersionId() >= 80100) { + return TrinaryLogic::createNo(); + } + + return TrinaryLogic::createYes(); + } + + if ($this->driverType === DriverDetector::PDO_PGSQL) { // @phpstan-ignore-line always true, but keep it readable + if ($type->isBoolean()->yes()) { + if ($this->phpVersion->getVersionId() >= 80100) { + return TrinaryLogic::createFromBoolean($stringifyFetches); + } + + return TrinaryLogic::createNo(); + } + + if ($type->isFloat()->yes()) { + if ($this->phpVersion->getVersionId() >= 80400) { + return TrinaryLogic::createFromBoolean($stringifyFetches); + } + + return TrinaryLogic::createYes(); + } + + return TrinaryLogic::createFromBoolean($stringifyFetches); } } - return false; + if ($this->driverType === DriverDetector::PGSQL || $this->driverType === DriverDetector::SQLITE3 || $this->driverType === DriverDetector::MYSQLI) { + return TrinaryLogic::createNo(); + } + + return TrinaryLogic::createMaybe(); + } + + private function isSupportedDriver(): bool + { + return in_array($this->driverType, [ + DriverDetector::MYSQLI, + DriverDetector::PDO_MYSQL, + DriverDetector::PGSQL, + DriverDetector::PDO_PGSQL, + DriverDetector::SQLITE3, + DriverDetector::PDO_SQLITE, + ], true); + } + + private function simpleStringify(Type $type): Type + { + return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if ($type instanceof IntegerType || $type instanceof FloatType || $type instanceof BooleanType) { + return $type->toString(); + } + + return $traverse($type); + }); + } + + private function simpleFloatify(Type $type): Type + { + return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if ($type instanceof IntegerType || $type instanceof BooleanType || $type instanceof StringType) { + return $type->toFloat(); + } + + return $traverse($type); + }); } } diff --git a/src/Type/Doctrine/Query/QueryType.php b/src/Type/Doctrine/Query/QueryType.php index b0cb7968..53842dd2 100644 --- a/src/Type/Doctrine/Query/QueryType.php +++ b/src/Type/Doctrine/Query/QueryType.php @@ -2,8 +2,8 @@ namespace PHPStan\Type\Doctrine\Query; -use PHPStan\TrinaryLogic; use PHPStan\Type\Generic\GenericObjectType; +use PHPStan\Type\IsSuperTypeOfResult; use PHPStan\Type\MixedType; use PHPStan\Type\Type; @@ -11,14 +11,11 @@ class QueryType extends GenericObjectType { - /** @var Type */ - private $indexType; + private Type $indexType; - /** @var Type */ - private $resultType; + private Type $resultType; - /** @var string */ - private $dql; + private string $dql; public function __construct(string $dql, ?Type $indexType = null, ?Type $resultType = null, ?Type $subtractedType = null) { @@ -47,10 +44,10 @@ public function changeSubtractedType(?Type $subtractedType): Type return new self('Doctrine\ORM\Query', $this->indexType, $this->resultType, $subtractedType); } - public function isSuperTypeOf(Type $type): TrinaryLogic + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult { if ($type instanceof self) { - return TrinaryLogic::createFromBoolean($this->equals($type)); + return IsSuperTypeOfResult::createFromBoolean($this->equals($type)); } return parent::isSuperTypeOf($type); diff --git a/src/Type/Doctrine/QueryBuilder/BranchingQueryBuilderType.php b/src/Type/Doctrine/QueryBuilder/BranchingQueryBuilderType.php index e9c7bfb8..473dffb5 100644 --- a/src/Type/Doctrine/QueryBuilder/BranchingQueryBuilderType.php +++ b/src/Type/Doctrine/QueryBuilder/BranchingQueryBuilderType.php @@ -2,7 +2,7 @@ namespace PHPStan\Type\Doctrine\QueryBuilder; -use PHPStan\TrinaryLogic; +use PHPStan\Type\IsSuperTypeOfResult; use PHPStan\Type\Type; use function array_keys; use function count; @@ -35,10 +35,10 @@ public function equals(Type $type): bool return parent::equals($type); } - public function isSuperTypeOf(Type $type): TrinaryLogic + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult { if ($type instanceof parent) { - return TrinaryLogic::createFromBoolean($this->equals($type)); + return IsSuperTypeOfResult::createFromBoolean($this->equals($type)); } return parent::isSuperTypeOf($type); diff --git a/src/Type/Doctrine/QueryBuilder/CreateQueryBuilderDynamicReturnTypeExtension.php b/src/Type/Doctrine/QueryBuilder/CreateQueryBuilderDynamicReturnTypeExtension.php index 263f2284..3fe7e9b1 100644 --- a/src/Type/Doctrine/QueryBuilder/CreateQueryBuilderDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/QueryBuilder/CreateQueryBuilderDynamicReturnTypeExtension.php @@ -11,19 +11,13 @@ class CreateQueryBuilderDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension { - /** @var string|null */ - private $queryBuilderClass; - - /** @var bool */ - private $fasterVersion; + private ?string $queryBuilderClass = null; public function __construct( - ?string $queryBuilderClass, - bool $fasterVersion + ?string $queryBuilderClass ) { $this->queryBuilderClass = $queryBuilderClass; - $this->fasterVersion = $fasterVersion; } public function getClass(): string @@ -42,13 +36,8 @@ public function getTypeFromMethodCall( Scope $scope ): Type { - $class = SimpleQueryBuilderType::class; - if (!$this->fasterVersion) { - $class = BranchingQueryBuilderType::class; - } - - return new $class( - $this->queryBuilderClass ?? 'Doctrine\ORM\QueryBuilder' + return new BranchingQueryBuilderType( + $this->queryBuilderClass ?? 'Doctrine\ORM\QueryBuilder', ); } diff --git a/src/Type/Doctrine/QueryBuilder/EntityRepositoryCreateQueryBuilderDynamicReturnTypeExtension.php b/src/Type/Doctrine/QueryBuilder/EntityRepositoryCreateQueryBuilderDynamicReturnTypeExtension.php index a9ea14e5..44201e45 100644 --- a/src/Type/Doctrine/QueryBuilder/EntityRepositoryCreateQueryBuilderDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/QueryBuilder/EntityRepositoryCreateQueryBuilderDynamicReturnTypeExtension.php @@ -8,7 +8,6 @@ use PhpParser\Node\Scalar\String_; use PHPStan\Analyser\Scope; use PHPStan\Reflection\MethodReflection; -use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Type\DynamicMethodReturnTypeExtension; use PHPStan\Type\Type; use function array_unshift; @@ -31,17 +30,17 @@ public function getTypeFromMethodCall( MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope - ): Type + ): ?Type { $entityNameExpr = new MethodCall($methodCall->var, new Identifier('getEntityName')); $entityNameExprType = $scope->getType($entityNameExpr); - if ($entityNameExprType->isClassStringType()->yes() && count($entityNameExprType->getClassStringObjectType()->getObjectClassNames()) === 1) { + if ($entityNameExprType->isClassString()->yes() && count($entityNameExprType->getClassStringObjectType()->getObjectClassNames()) === 1) { $entityNameExpr = new String_($entityNameExprType->getClassStringObjectType()->getObjectClassNames()[0]); } if (!isset($methodCall->getArgs()[0])) { - return ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); + return null; } $fromArgs = $methodCall->getArgs(); diff --git a/src/Type/Doctrine/QueryBuilder/Expr/BaseExpressionDynamicReturnTypeExtension.php b/src/Type/Doctrine/QueryBuilder/Expr/BaseExpressionDynamicReturnTypeExtension.php index 8564d374..7dd9729e 100644 --- a/src/Type/Doctrine/QueryBuilder/Expr/BaseExpressionDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/QueryBuilder/Expr/BaseExpressionDynamicReturnTypeExtension.php @@ -5,7 +5,6 @@ use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; use PHPStan\Reflection\MethodReflection; -use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Rules\Doctrine\ORM\DynamicQueryBuilderArgumentException; use PHPStan\Type\Doctrine\ArgumentsProcessor; use PHPStan\Type\DynamicMethodReturnTypeExtension; @@ -18,8 +17,7 @@ class BaseExpressionDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension { - /** @var ArgumentsProcessor */ - private $argumentsProcessor; + private ArgumentsProcessor $argumentsProcessor; public function __construct( ArgumentsProcessor $argumentsProcessor @@ -38,25 +36,23 @@ public function isMethodSupported(MethodReflection $methodReflection): bool return in_array($methodReflection->getName(), ['add', 'addMultiple'], true); } - public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type { - $defaultReturnType = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(); - try { $args = $this->argumentsProcessor->processArgs($scope, $methodReflection->getName(), $methodCall->getArgs()); } catch (DynamicQueryBuilderArgumentException $e) { - return $defaultReturnType; + return null; } $calledOnType = $scope->getType($methodCall->var); if (!$calledOnType instanceof ExprType) { - return $defaultReturnType; + return null; } $expr = $calledOnType->getExprObject(); if (!method_exists($expr, $methodReflection->getName())) { - return $defaultReturnType; + return null; } $exprValue = $expr->{$methodReflection->getName()}(...$args); diff --git a/src/Type/Doctrine/QueryBuilder/Expr/ExprType.php b/src/Type/Doctrine/QueryBuilder/Expr/ExprType.php index 00610d03..30a97a59 100644 --- a/src/Type/Doctrine/QueryBuilder/Expr/ExprType.php +++ b/src/Type/Doctrine/QueryBuilder/Expr/ExprType.php @@ -8,8 +8,7 @@ class ExprType extends ObjectType { - /** @var object */ - private $exprObject; + private object $exprObject; /** * @param object $exprObject diff --git a/src/Type/Doctrine/QueryBuilder/Expr/ExpressionBuilderDynamicReturnTypeExtension.php b/src/Type/Doctrine/QueryBuilder/Expr/ExpressionBuilderDynamicReturnTypeExtension.php index f9033aa7..a6a6896a 100644 --- a/src/Type/Doctrine/QueryBuilder/Expr/ExpressionBuilderDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/QueryBuilder/Expr/ExpressionBuilderDynamicReturnTypeExtension.php @@ -6,7 +6,6 @@ use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; use PHPStan\Reflection\MethodReflection; -use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Rules\Doctrine\ORM\DynamicQueryBuilderArgumentException; use PHPStan\Type\Doctrine\ArgumentsProcessor; use PHPStan\Type\Doctrine\ObjectMetadataResolver; @@ -19,11 +18,9 @@ class ExpressionBuilderDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension { - /** @var ObjectMetadataResolver */ - private $objectMetadataResolver; + private ObjectMetadataResolver $objectMetadataResolver; - /** @var ArgumentsProcessor */ - private $argumentsProcessor; + private ArgumentsProcessor $argumentsProcessor; public function __construct( ObjectMetadataResolver $objectMetadataResolver, @@ -44,17 +41,15 @@ public function isMethodSupported(MethodReflection $methodReflection): bool return true; } - public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type { - $defaultReturnType = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(); - $objectManager = $this->objectMetadataResolver->getObjectManager(); if ($objectManager === null) { - return $defaultReturnType; + return null; } $entityManagerInterface = 'Doctrine\ORM\EntityManagerInterface'; if (!$objectManager instanceof $entityManagerInterface) { - return $defaultReturnType; + return null; } /** @var EntityManagerInterface $objectManager */ @@ -65,7 +60,7 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method try { $args = $this->argumentsProcessor->processArgs($scope, $methodReflection->getName(), $methodCall->getArgs()); } catch (DynamicQueryBuilderArgumentException $e) { - return $defaultReturnType; + return null; } $calledOnType = $scope->getType($methodCall->var); @@ -76,7 +71,7 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method } if (!method_exists($expr, $methodReflection->getName())) { - return $defaultReturnType; + return null; } $exprValue = $expr->{$methodReflection->getName()}(...$args); diff --git a/src/Type/Doctrine/QueryBuilder/Expr/NewExprDynamicReturnTypeExtension.php b/src/Type/Doctrine/QueryBuilder/Expr/NewExprDynamicReturnTypeExtension.php index 1690c63c..79206e1c 100644 --- a/src/Type/Doctrine/QueryBuilder/Expr/NewExprDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/QueryBuilder/Expr/NewExprDynamicReturnTypeExtension.php @@ -18,15 +18,16 @@ class NewExprDynamicReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension { - /** @var ArgumentsProcessor */ - private $argumentsProcessor; + private ArgumentsProcessor $argumentsProcessor; - /** @var string */ - private $class; + /** @var class-string */ + private string $class; - /** @var ReflectionProvider */ - private $reflectionProvider; + private ReflectionProvider $reflectionProvider; + /** + * @param class-string $class + */ public function __construct( ArgumentsProcessor $argumentsProcessor, string $class, @@ -68,8 +69,8 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, ...$this->argumentsProcessor->processArgs( $scope, $methodReflection->getName(), - $methodCall->getArgs() - ) + $methodCall->getArgs(), + ), ); } catch (DynamicQueryBuilderArgumentException $e) { return new ObjectType($this->reflectionProvider->getClassName($className)); diff --git a/src/Type/Doctrine/QueryBuilder/OtherMethodQueryBuilderParser.php b/src/Type/Doctrine/QueryBuilder/OtherMethodQueryBuilderParser.php index 4375c17a..153291f5 100644 --- a/src/Type/Doctrine/QueryBuilder/OtherMethodQueryBuilderParser.php +++ b/src/Type/Doctrine/QueryBuilder/OtherMethodQueryBuilderParser.php @@ -28,25 +28,19 @@ class OtherMethodQueryBuilderParser { - /** @var bool */ - private $descendIntoOtherMethods; + private Parser $parser; - /** @var Parser */ - private $parser; - - /** @var Container */ - private $container; + private Container $container; /** * Null if the method is currently being processed * * @var array|null> */ - private $cache = []; + private array $cache = []; - public function __construct(bool $descendIntoOtherMethods, Parser $parser, Container $container) + public function __construct(Parser $parser, Container $container) { - $this->descendIntoOtherMethods = $descendIntoOtherMethods; $this->parser = $parser; $this->container = $container; } @@ -56,10 +50,6 @@ public function __construct(bool $descendIntoOtherMethods, Parser $parser, Conta */ public function findQueryBuilderTypesInCalledMethod(Scope $scope, MethodReflection $methodReflection): array { - if (!$this->descendIntoOtherMethods) { - return []; - } - $methodName = $methodReflection->getName(); $className = $methodReflection->getDeclaringClass()->getName(); $fileName = $methodReflection->getDeclaringClass()->getFileName(); diff --git a/src/Type/Doctrine/QueryBuilder/QueryBuilderGetDqlDynamicReturnTypeExtension.php b/src/Type/Doctrine/QueryBuilder/QueryBuilderGetDqlDynamicReturnTypeExtension.php index 3b7dfb19..69ceb824 100644 --- a/src/Type/Doctrine/QueryBuilder/QueryBuilderGetDqlDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/QueryBuilder/QueryBuilderGetDqlDynamicReturnTypeExtension.php @@ -13,9 +13,12 @@ class QueryBuilderGetDqlDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension { - /** @var string|null */ - private $queryBuilderClass; + /** @var class-string|null */ + private ?string $queryBuilderClass = null; + /** + * @param class-string|null $queryBuilderClass + */ public function __construct( ?string $queryBuilderClass ) @@ -41,7 +44,7 @@ public function getTypeFromMethodCall( { $type = $scope->getType(new MethodCall( new MethodCall($methodCall->var, new Identifier('getQuery')), - new Identifier('getDQL') + new Identifier('getDQL'), )); return TypeCombinator::removeNull($type); diff --git a/src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php b/src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php index c5df245e..98fdefb3 100644 --- a/src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php @@ -11,8 +11,9 @@ use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Identifier; use PHPStan\Analyser\Scope; +use PHPStan\Doctrine\Driver\DriverDetector; +use PHPStan\Php\PhpVersion; use PHPStan\Reflection\MethodReflection; -use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Rules\Doctrine\ORM\DynamicQueryBuilderArgumentException; use PHPStan\Type\Doctrine\ArgumentsProcessor; use PHPStan\Type\Doctrine\DescriptorRegistry; @@ -54,29 +55,37 @@ class QueryBuilderGetQueryDynamicReturnTypeExtension implements DynamicMethodRet 'orhaving', ]; - /** @var ObjectMetadataResolver */ - private $objectMetadataResolver; + private ObjectMetadataResolver $objectMetadataResolver; - /** @var ArgumentsProcessor */ - private $argumentsProcessor; + private ArgumentsProcessor $argumentsProcessor; - /** @var string|null */ - private $queryBuilderClass; + /** @var class-string|null */ + private ?string $queryBuilderClass = null; - /** @var DescriptorRegistry */ - private $descriptorRegistry; + private DescriptorRegistry $descriptorRegistry; + private PhpVersion $phpVersion; + + private DriverDetector $driverDetector; + + /** + * @param class-string|null $queryBuilderClass + */ public function __construct( ObjectMetadataResolver $objectMetadataResolver, ArgumentsProcessor $argumentsProcessor, ?string $queryBuilderClass, - DescriptorRegistry $descriptorRegistry + DescriptorRegistry $descriptorRegistry, + PhpVersion $phpVersion, + DriverDetector $driverDetector ) { $this->objectMetadataResolver = $objectMetadataResolver; $this->argumentsProcessor = $argumentsProcessor; $this->queryBuilderClass = $queryBuilderClass; $this->descriptorRegistry = $descriptorRegistry; + $this->phpVersion = $phpVersion; + $this->driverDetector = $driverDetector; } public function getClass(): string @@ -93,26 +102,22 @@ public function getTypeFromMethodCall( MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope - ): Type + ): ?Type { $calledOnType = $scope->getType($methodCall->var); - $defaultReturnType = ParametersAcceptorSelector::selectFromArgs( - $scope, - $methodCall->getArgs(), - $methodReflection->getVariants() - )->getReturnType(); + $queryBuilderTypes = DoctrineTypeUtils::getQueryBuilderTypes($calledOnType); if (count($queryBuilderTypes) === 0) { - return $defaultReturnType; + return null; } $objectManager = $this->objectMetadataResolver->getObjectManager(); if ($objectManager === null) { - return $defaultReturnType; + return null; } $entityManagerInterface = 'Doctrine\ORM\EntityManagerInterface'; if (!$objectManager instanceof $entityManagerInterface) { - return $defaultReturnType; + return null; } /** @var EntityManagerInterface $objectManager */ @@ -150,7 +155,7 @@ public function getTypeFromMethodCall( try { $args = $this->argumentsProcessor->processArgs($scope, $methodName, array_slice($calledMethodCall->getArgs(), 0, 1)); } catch (DynamicQueryBuilderArgumentException $e) { - return $defaultReturnType; + return null; } if (count($args) === 1) { $queryBuilder->set($args[0], $args[0]); @@ -168,13 +173,13 @@ public function getTypeFromMethodCall( if (in_array($lowerMethodName, self::METHODS_NOT_AFFECTING_RESULT_TYPE, true)) { continue; } - return $defaultReturnType; + return null; } try { $queryBuilder->{$methodName}(...$args); } catch (Throwable $e) { - return $defaultReturnType; + return null; } } @@ -195,7 +200,7 @@ private function getQueryType(string $dql): Type try { $query = $em->createQuery($dql); - QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry); + QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry, $this->phpVersion, $this->driverDetector); } catch (ORMException | DBALException | CommonException | MappingException | \Doctrine\ORM\Exception\ORMException $e) { return new QueryType($dql, null); } catch (AssertionError $e) { diff --git a/src/Type/Doctrine/QueryBuilder/QueryBuilderMethodDynamicReturnTypeExtension.php b/src/Type/Doctrine/QueryBuilder/QueryBuilderMethodDynamicReturnTypeExtension.php index cb76ba29..0c6d9266 100644 --- a/src/Type/Doctrine/QueryBuilder/QueryBuilderMethodDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/QueryBuilder/QueryBuilderMethodDynamicReturnTypeExtension.php @@ -7,7 +7,6 @@ use PhpParser\Node\Identifier; use PHPStan\Analyser\Scope; use PHPStan\Reflection\MethodReflection; -use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Type\Doctrine\DoctrineTypeUtils; use PHPStan\Type\DynamicMethodReturnTypeExtension; use PHPStan\Type\MixedType; @@ -23,9 +22,12 @@ class QueryBuilderMethodDynamicReturnTypeExtension implements DynamicMethodRetur private const MAX_COMBINATIONS = 16; - /** @var string|null */ - private $queryBuilderClass; + /** @var class-string|null */ + private ?string $queryBuilderClass = null; + /** + * @param class-string|null $queryBuilderClass + */ public function __construct( ?string $queryBuilderClass ) @@ -40,9 +42,7 @@ public function getClass(): string public function isMethodSupported(MethodReflection $methodReflection): bool { - $returnType = ParametersAcceptorSelector::selectSingle( - $methodReflection->getVariants() - )->getReturnType(); + $returnType = $methodReflection->getVariants()[0]->getReturnType(); if ($returnType instanceof MixedType) { return false; } diff --git a/src/Type/Doctrine/QueryBuilder/QueryBuilderType.php b/src/Type/Doctrine/QueryBuilder/QueryBuilderType.php index 729cda17..f707888c 100644 --- a/src/Type/Doctrine/QueryBuilder/QueryBuilderType.php +++ b/src/Type/Doctrine/QueryBuilder/QueryBuilderType.php @@ -14,7 +14,7 @@ abstract class QueryBuilderType extends ObjectType { /** @var array */ - private $methodCalls = []; + private array $methodCalls = []; final public function __construct( string $className, diff --git a/src/Type/Doctrine/QueryBuilder/QueryBuilderTypeSpecifyingExtension.php b/src/Type/Doctrine/QueryBuilder/QueryBuilderTypeSpecifyingExtension.php index 4b72f650..66b18f71 100644 --- a/src/Type/Doctrine/QueryBuilder/QueryBuilderTypeSpecifyingExtension.php +++ b/src/Type/Doctrine/QueryBuilder/QueryBuilderTypeSpecifyingExtension.php @@ -24,12 +24,14 @@ class QueryBuilderTypeSpecifyingExtension implements MethodTypeSpecifyingExtensi private const MAX_COMBINATIONS = 16; - /** @var string|null */ - private $queryBuilderClass; + /** @var class-string|null */ + private ?string $queryBuilderClass = null; - /** @var TypeSpecifier */ - private $typeSpecifier; + private TypeSpecifier $typeSpecifier; + /** + * @param class-string|null $queryBuilderClass + */ public function __construct(?string $queryBuilderClass) { $this->queryBuilderClass = $queryBuilderClass; @@ -62,7 +64,7 @@ public function specifyTypes(MethodReflection $methodReflection, MethodCall $nod $returnType = ParametersAcceptorSelector::selectFromArgs( $scope, $node->getArgs(), - $methodReflection->getVariants() + $methodReflection->getVariants(), )->getReturnType(); if ($returnType instanceof MixedType) { return new SpecifiedTypes([]); @@ -100,8 +102,8 @@ public function specifyTypes(MethodReflection $methodReflection, MethodCall $nod $queryBuilderNode, TypeCombinator::union(...$resultTypes), TypeSpecifierContext::createTruthy(), - true - ); + $scope, + )->setAlwaysOverwriteTypes(); } } diff --git a/src/Type/Doctrine/QueryBuilder/ReturnQueryBuilderExpressionTypeResolverExtension.php b/src/Type/Doctrine/QueryBuilder/ReturnQueryBuilderExpressionTypeResolverExtension.php index 2f780f22..5f308ba1 100644 --- a/src/Type/Doctrine/QueryBuilder/ReturnQueryBuilderExpressionTypeResolverExtension.php +++ b/src/Type/Doctrine/QueryBuilder/ReturnQueryBuilderExpressionTypeResolverExtension.php @@ -23,8 +23,7 @@ class ReturnQueryBuilderExpressionTypeResolverExtension implements ExpressionTypeResolverExtension { - /** @var OtherMethodQueryBuilderParser */ - private $otherMethodQueryBuilderParser; + private OtherMethodQueryBuilderParser $otherMethodQueryBuilderParser; public function __construct( OtherMethodQueryBuilderParser $otherMethodQueryBuilderParser diff --git a/src/Type/Doctrine/QueryBuilder/SimpleQueryBuilderType.php b/src/Type/Doctrine/QueryBuilder/SimpleQueryBuilderType.php deleted file mode 100644 index 98660de8..00000000 --- a/src/Type/Doctrine/QueryBuilder/SimpleQueryBuilderType.php +++ /dev/null @@ -1,33 +0,0 @@ -getMethodCalls()) === count($type->getMethodCalls()); - } - - return parent::equals($type); - } - - public function isSuperTypeOf(Type $type): TrinaryLogic - { - if ($type instanceof parent) { - $thisCount = count($this->getMethodCalls()); - $thatCount = count($type->getMethodCalls()); - - return TrinaryLogic::createFromBoolean($thisCount === $thatCount); - } - - return parent::isSuperTypeOf($type); - } - -} diff --git a/stubs/Collections/ArrayCollection.stub b/stubs/Collections/ArrayCollection.stub index 2ecb9ea6..34d07450 100644 --- a/stubs/Collections/ArrayCollection.stub +++ b/stubs/Collections/ArrayCollection.stub @@ -2,6 +2,8 @@ namespace Doctrine\Common\Collections; +use Closure; + /** * @template TKey of array-key * @template T @@ -11,4 +13,33 @@ namespace Doctrine\Common\Collections; class ArrayCollection implements Collection, Selectable { + /** + * @param-immediately-invoked-callable $p + * + * @param Closure(T, TKey):bool $p + * + * @return static + */ + public function filter(Closure $p); + + /** + * @param-immediately-invoked-callable $func + * + * @param Closure(T):U $func + * + * @return static + * + * @template U + */ + public function map(Closure $func); + + /** + * @param-immediately-invoked-callable $p + * + * @param Closure(TKey, T):bool $p + * + * @return array{0: static, 1: static} + */ + public function partition(Closure $p); + } diff --git a/stubs/Collections/Collection.stub b/stubs/Collections/Collection.stub index 455733c8..05139edd 100644 --- a/stubs/Collections/Collection.stub +++ b/stubs/Collections/Collection.stub @@ -3,6 +3,7 @@ namespace Doctrine\Common\Collections; use ArrayAccess; +use Closure; use Countable; use IteratorAggregate; @@ -21,7 +22,7 @@ interface Collection extends Countable, IteratorAggregate, ArrayAccess, Readable * * @param T $element * - * @return true + * @return void */ public function add($element) {} @@ -43,4 +44,33 @@ interface Collection extends Countable, IteratorAggregate, ArrayAccess, Readable */ public function removeElement($element) {} + /** + * @param-immediately-invoked-callable $p + * + * @param Closure(T, TKey):bool $p + * + * @return Collection + */ + public function filter(Closure $p); + + /** + * @param-immediately-invoked-callable $func + * + * @param Closure(T):U $func + * + * @return Collection + * + * @template U + */ + public function map(Closure $func); + + /** + * @param-immediately-invoked-callable $p + * + * @param Closure(TKey, T):bool $p + * + * @return array{0: Collection, 1: Collection} + */ + public function partition(Closure $p); + } diff --git a/stubs/Collections/Collection1.stub b/stubs/Collections/Collection1.stub new file mode 100644 index 00000000..0f5ad708 --- /dev/null +++ b/stubs/Collections/Collection1.stub @@ -0,0 +1,76 @@ + + * @extends ArrayAccess + * @extends ReadableCollection + */ +interface Collection extends Countable, IteratorAggregate, ArrayAccess, ReadableCollection +{ + + /** + * @phpstan-impure + * + * @param T $element + * + * @return true + */ + public function add($element) {} + + /** + * @phpstan-impure + * + * @param TKey $key + * + * @return T|null + */ + public function remove($key) {} + + /** + * @phpstan-impure + * + * @param T $element + * + * @return bool + */ + public function removeElement($element) {} + + /** + * @param-immediately-invoked-callable $p + * + * @param Closure(T, TKey):bool $p + * + * @return Collection + */ + public function filter(Closure $p); + + /** + * @param-immediately-invoked-callable $func + * + * @param Closure(T):U $func + * + * @return Collection + * + * @template U + */ + public function map(Closure $func); + + /** + * @param-immediately-invoked-callable $p + * + * @param Closure(TKey, T):bool $p + * + * @return array{0: Collection, 1: Collection} + */ + public function partition(Closure $p); + +} diff --git a/stubs/Collections/ReadableCollection.stub b/stubs/Collections/ReadableCollection.stub index df07c526..d9fba5ae 100644 --- a/stubs/Collections/ReadableCollection.stub +++ b/stubs/Collections/ReadableCollection.stub @@ -2,6 +2,7 @@ namespace Doctrine\Common\Collections; +use Closure; use Countable; use IteratorAggregate; @@ -13,4 +14,73 @@ use IteratorAggregate; interface ReadableCollection extends Countable, IteratorAggregate { + /** + * @param-immediately-invoked-callable $p + * + * @param Closure(TKey, T):bool $p + * + * @return bool + */ + public function exists(Closure $p); + + /** + * @param-immediately-invoked-callable $p + * + * @param Closure(T, TKey):bool $p + * + * @return ReadableCollection + */ + public function filter(Closure $p); + + /** + * @param-immediately-invoked-callable $func + * + * @param Closure(T):U $func + * + * @return ReadableCollection + * + * @template U + */ + public function map(Closure $func); + + /** + * @param-immediately-invoked-callable $p + * + * @param Closure(TKey, T):bool $p + * + * @return array{0: ReadableCollection, 1: ReadableCollection} + */ + public function partition(Closure $p); + + /** + * @param-immediately-invoked-callable $p + * + * @param Closure(TKey, T):bool $p + * + * @return bool TRUE, if the predicate yields TRUE for all elements, FALSE otherwise. + */ + public function forAll(Closure $p); + + /** + * @param-immediately-invoked-callable $p + * + * @param Closure(TKey, T):bool $p + * + * @return T|null + */ + public function findFirst(Closure $p); + + /** + * @param-immediately-invoked-callable $func + * + * @param Closure(TReturn|TInitial, T):TReturn $func + * @param TInitial $initial + * + * @return TReturn|TInitial + * + * @template TReturn + * @template TInitial + */ + public function reduce(Closure $func, mixed $initial = null); + } diff --git a/stubs/Collections/ReadableCollection1.stub b/stubs/Collections/ReadableCollection1.stub new file mode 100644 index 00000000..dec73af0 --- /dev/null +++ b/stubs/Collections/ReadableCollection1.stub @@ -0,0 +1,86 @@ + + */ +interface ReadableCollection extends Countable, IteratorAggregate +{ + + /** + * @param-immediately-invoked-callable $p + * + * @param Closure(TKey, T):bool $p + * + * @return bool + */ + public function exists(Closure $p); + + /** + * @param-immediately-invoked-callable $p + * + * @param Closure(T, TKey):bool $p + * + * @return ReadableCollection + */ + public function filter(Closure $p); + + /** + * @param-immediately-invoked-callable $func + * + * @param Closure(T):U $func + * + * @return Collection + * + * @template U + */ + public function map(Closure $func); + + /** + * @param-immediately-invoked-callable $p + * + * @param Closure(TKey, T):bool $p + * + * @return array{0: ReadableCollection, 1: ReadableCollection} + */ + public function partition(Closure $p); + + /** + * @param-immediately-invoked-callable $p + * + * @param Closure(TKey, T):bool $p + * + * @return bool TRUE, if the predicate yields TRUE for all elements, FALSE otherwise. + */ + public function forAll(Closure $p); + + /** + * @param-immediately-invoked-callable $p + * + * @param Closure(TKey, T):bool $p + * + * @return T|null + */ + public function findFirst(Closure $p); + + /** + * @param-immediately-invoked-callable $func + * + * @param Closure(TReturn|TInitial, T):TReturn $func + * @param TInitial $initial + * + * @return TReturn|TInitial + * + * @template TReturn + * @template TInitial + */ + public function reduce(Closure $func, mixed $initial = null); + +} diff --git a/stubs/bleedingEdge/DBAL/ArrayParameterType.stub b/stubs/DBAL/ArrayParameterType.stub similarity index 100% rename from stubs/bleedingEdge/DBAL/ArrayParameterType.stub rename to stubs/DBAL/ArrayParameterType.stub diff --git a/stubs/DBAL/Connection.stub b/stubs/DBAL/Connection.stub index 8e88410a..15c8e6e1 100644 --- a/stubs/DBAL/Connection.stub +++ b/stubs/DBAL/Connection.stub @@ -2,7 +2,76 @@ namespace Doctrine\DBAL; +use Closure; +use Doctrine\DBAL\Cache\CacheException; +use Doctrine\DBAL\Cache\QueryCacheProfile; +use Doctrine\DBAL\Types\Type; +use Throwable; + class Connection { + /** + * Executes an SQL statement with the given parameters and returns the number of affected rows. + * + * Could be used for: + * - DML statements: INSERT, UPDATE, DELETE, etc. + * - DDL statements: CREATE, DROP, ALTER, etc. + * - DCL statements: GRANT, REVOKE, etc. + * - Session control statements: ALTER SESSION, SET, DECLARE, etc. + * - Other statements that don't yield a row set. + * + * This method supports PDO binding types as well as DBAL mapping types. + * + * @param __doctrine-literal-string $sql SQL statement + * @param list|array $params Statement parameters + * @param array|array $types Parameter types + * + * @return int|string The number of affected rows. + * + * @throws Exception + */ + public function executeStatement($sql, array $params = [], array $types = []); + + /** + * Executes an, optionally parameterized, SQL query. + * + * If the query is parametrized, a prepared statement is used. + * If an SQLLogger is configured, the execution is logged. + * + * @param __doctrine-literal-string $sql SQL query + * @param list|array $params Query parameters + * @param array|array $types Parameter types + * + * @throws Exception + */ + public function executeQuery( + string $sql, + array $params = [], + $types = [], + ?QueryCacheProfile $qcp = null + ): Result; + + /** + * Executes a caching query. + * + * @param __doctrine-literal-string $sql SQL query + * @param list|array $params Query parameters + * @param array|array $types Parameter types + * + * @throws CacheException + * @throws Exception + */ + public function executeCacheQuery($sql, $params, $types, QueryCacheProfile $qcp): Result; + + /** + * @param-immediately-invoked-callable $func + * @param Closure(self): T $func + * @return T + * + * @template T + * + * @throws Throwable + */ + public function transactional(Closure $func); } diff --git a/stubs/bleedingEdge/DBAL/Connection4.stub b/stubs/DBAL/Connection4.stub similarity index 81% rename from stubs/bleedingEdge/DBAL/Connection4.stub rename to stubs/DBAL/Connection4.stub index 0e6edd58..77b13d78 100644 --- a/stubs/bleedingEdge/DBAL/Connection4.stub +++ b/stubs/DBAL/Connection4.stub @@ -2,9 +2,11 @@ namespace Doctrine\DBAL; +use Closure; use Doctrine\DBAL\Cache\CacheException; use Doctrine\DBAL\Cache\QueryCacheProfile; use Doctrine\DBAL\Types\Type; +use Throwable; /** * @phpstan-type WrapperParameterType = string|Type|ParameterType|ArrayParameterType @@ -24,7 +26,7 @@ class Connection * * This method supports PDO binding types as well as DBAL mapping types. * - * @param literal-string $sql SQL statement + * @param __doctrine-literal-string $sql SQL statement * @param list|array $params Statement parameters * @param WrapperParameterTypeArray $types Parameter types * @@ -40,7 +42,7 @@ class Connection * If the query is parametrized, a prepared statement is used. * If an SQLLogger is configured, the execution is logged. * - * @param literal-string $sql SQL query + * @param __doctrine-literal-string $sql SQL query * @param list|array $params Query parameters * @param WrapperParameterTypeArray $types Parameter types * @@ -56,7 +58,7 @@ class Connection /** * Executes a caching query. * - * @param literal-string $sql SQL query + * @param __doctrine-literal-string $sql SQL query * @param list|array $params Query parameters * @param WrapperParameterTypeArray $types Parameter types * @@ -65,4 +67,15 @@ class Connection */ public function executeCacheQuery($sql, $params, $types, QueryCacheProfile $qcp): Result; + /** + * @param-immediately-invoked-callable $func + * @param Closure(self): T $func + * @return T + * + * @template T + * + * @throws Throwable + */ + public function transactional(Closure $func); + } diff --git a/stubs/bleedingEdge/DBAL/ParameterType.stub b/stubs/DBAL/ParameterType.stub similarity index 100% rename from stubs/bleedingEdge/DBAL/ParameterType.stub rename to stubs/DBAL/ParameterType.stub diff --git a/stubs/DocumentManager.stub b/stubs/DocumentManager.stub index 1e73316d..648ec03c 100644 --- a/stubs/DocumentManager.stub +++ b/stubs/DocumentManager.stub @@ -15,6 +15,7 @@ class DocumentManager implements ObjectManager * @phpstan-param integer|null $lockMode * @phpstan-param integer|null $lockVersion * @phpstan-return T|null + * @phpstan-impure */ public function find($documentName, $identifier, $lockMode = null, $lockVersion = null); diff --git a/stubs/DocumentRepository.stub b/stubs/DocumentRepository.stub index 34b970cb..e3bf3df6 100644 --- a/stubs/DocumentRepository.stub +++ b/stubs/DocumentRepository.stub @@ -16,11 +16,13 @@ class DocumentRepository implements ObjectRepository * @phpstan-param int|null $lockMode * @phpstan-param int|null $lockVersion * @phpstan-return TDocumentClass|null + * @phpstan-impure */ public function find($id, $lockMode = null, $lockVersion = null); /** * @phpstan-return array + * @phpstan-impure */ public function findAll(); @@ -30,12 +32,14 @@ class DocumentRepository implements ObjectRepository * @phpstan-param int|null $limit * @phpstan-param int|null $skip * @phpstan-return array + * @phpstan-impure */ public function findBy(array $criteria, ?array $sort = null, $limit = null, $skip = null); /** * @phpstan-param array $criteria The criteria. * @phpstan-return TDocumentClass|null + * @phpstan-impure */ public function findOneBy(array $criteria); diff --git a/stubs/EntityManager.stub b/stubs/EntityManager.stub index 33a8af0e..6dc13e1c 100644 --- a/stubs/EntityManager.stub +++ b/stubs/EntityManager.stub @@ -14,6 +14,7 @@ class EntityManager implements EntityManagerInterface * @phpstan-param integer|null $lockMode * @phpstan-param integer|null $lockVersion * @phpstan-return T|null + * @phpstan-impure */ public function find($entityName, $id, $lockMode = null, $lockVersion = null); diff --git a/stubs/EntityManagerDecorator.stub b/stubs/EntityManagerDecorator.stub index 552cee9e..670d2e8a 100644 --- a/stubs/EntityManagerDecorator.stub +++ b/stubs/EntityManagerDecorator.stub @@ -16,6 +16,7 @@ class EntityManagerDecorator implements EntityManagerInterface * @phpstan-param integer|null $lockMode * @phpstan-param integer|null $lockVersion * @phpstan-return T|null + * @phpstan-impure */ public function find($entityName, $id, $lockMode = null, $lockVersion = null); diff --git a/stubs/EntityManagerInterface.stub b/stubs/EntityManagerInterface.stub index 87e8fbcb..5625f33c 100644 --- a/stubs/EntityManagerInterface.stub +++ b/stubs/EntityManagerInterface.stub @@ -15,6 +15,7 @@ interface EntityManagerInterface extends ObjectManager * @phpstan-param class-string $className * @phpstan-param mixed $id * @phpstan-return T|null + * @phpstan-impure */ public function find($className, $id); @@ -68,4 +69,13 @@ interface EntityManagerInterface extends ObjectManager */ public function getClassMetadata($className); + /** + * @param-immediately-invoked-callable $func + * @param callable(self): T $func + * @return T + * + * @template T + */ + public function wrapInTransaction(callable $func); + } diff --git a/stubs/EntityRepository.stub b/stubs/EntityRepository.stub index cfa838fd..77b5b337 100644 --- a/stubs/EntityRepository.stub +++ b/stubs/EntityRepository.stub @@ -20,11 +20,13 @@ class EntityRepository implements ObjectRepository * @phpstan-param int|null $lockMode * @phpstan-param int|null $lockVersion * @phpstan-return TEntityClass|null + * @phpstan-impure */ public function find($id, $lockMode = null, $lockVersion = null); /** * @phpstan-return list + * @phpstan-impure */ public function findAll(); @@ -34,6 +36,7 @@ class EntityRepository implements ObjectRepository * @phpstan-param int|null $limit * @phpstan-param int|null $offset * @phpstan-return list + * @phpstan-impure */ public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null); @@ -41,8 +44,9 @@ class EntityRepository implements ObjectRepository * @phpstan-param array $criteria The criteria. * @phpstan-param array|null $orderBy * @phpstan-return TEntityClass|null + * @phpstan-impure */ - public function findOneBy(array $criteria, array $orderBy = null); + public function findOneBy(array $criteria, ?array $orderBy = null); /** * @phpstan-return class-string @@ -63,4 +67,21 @@ class EntityRepository implements ObjectRepository */ public function matching(Criteria $criteria); + /** + * @param __doctrine-literal-string $alias + * @param __doctrine-literal-string|null $indexBy + * + * @return QueryBuilder + */ + public function createQueryBuilder($alias, $indexBy = null); + + /** + * @param array $criteria + * + * @return int<0, max> + * + * @phpstan-impure + */ + public function count(array $criteria); + } diff --git a/stubs/MongoClassMetadataInfo.stub b/stubs/MongoClassMetadataInfo.stub index cfdba938..94c7bcd2 100644 --- a/stubs/MongoClassMetadataInfo.stub +++ b/stubs/MongoClassMetadataInfo.stub @@ -16,6 +16,7 @@ class ClassMetadata implements BaseClassMetadata public $customRepositoryClassName; /** + * @readonly * @var class-string */ public $name; @@ -34,7 +35,7 @@ class ClassMetadata implements BaseClassMetadata public function getName(); /** - * @return ReflectionClass + * @return ReflectionClass */ public function getReflectionClass(); diff --git a/stubs/ORM/AbstractQuery.stub b/stubs/ORM/AbstractQuery.stub index 1b970ad0..25f7e320 100644 --- a/stubs/ORM/AbstractQuery.stub +++ b/stubs/ORM/AbstractQuery.stub @@ -4,6 +4,7 @@ namespace Doctrine\ORM; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\NonUniqueResultException; +use Doctrine\ORM\NoResultException; /** * @template-covariant TKey The type of column used in indexBy @@ -12,21 +13,75 @@ use Doctrine\ORM\NonUniqueResultException; abstract class AbstractQuery { + public const HYDRATE_OBJECT = 1; + /** * @param ArrayCollection|array $parameters * @return static */ public function setParameters($parameters) { - } - /** - * @return bool|float|int|string|null - * - * @throws NoResultException - * @throws NonUniqueResultException - */ - public function getSingleScalarResult(); + /** + * @phpstan-impure + * @param string|AbstractQuery::HYDRATE_* $hydrationMode + */ + public function getResult($hydrationMode = self::HYDRATE_OBJECT): mixed + { + } + + /** + * @phpstan-impure + * @return mixed[] + */ + public function getArrayResult(): array + { + } + + /** + * @phpstan-impure + * @return mixed[] + */ + public function getSingleColumnResult(): array + { + } + + /** + * @phpstan-impure + * @return mixed[] + */ + public function getScalarResult(): array + { + } + + /** + * @phpstan-impure + * @param string|AbstractQuery::HYDRATE_*|null $hydrationMode + * @throws NonUniqueResultException + */ + public function getOneOrNullResult($hydrationMode = null): mixed + { + } + + /** + * @phpstan-impure + * @param string|AbstractQuery::HYDRATE_*|null $hydrationMode + * @throws NonUniqueResultException + * @throws NoResultException + */ + public function getSingleResult($hydrationMode = null): mixed + { + } + + /** + * @phpstan-impure + * @return bool|float|int|string|null + * @throws NoResultException + * @throws NonUniqueResultException + */ + public function getSingleScalarResult() + { + } } diff --git a/stubs/ORM/Mapping/ClassMetadataInfo.stub b/stubs/ORM/Mapping/ClassMetadataInfo.stub index 243ff1bb..339acea5 100644 --- a/stubs/ORM/Mapping/ClassMetadataInfo.stub +++ b/stubs/ORM/Mapping/ClassMetadataInfo.stub @@ -54,7 +54,7 @@ class ClassMetadataInfo implements ClassMetadata public function getName(); /** - * @return ReflectionClass + * @return ReflectionClass */ public function getReflectionClass(); diff --git a/stubs/ORM/QueryBuilder.stub b/stubs/ORM/QueryBuilder.stub index db14c5b2..46b8e0ee 100644 --- a/stubs/ORM/QueryBuilder.stub +++ b/stubs/ORM/QueryBuilder.stub @@ -2,6 +2,8 @@ namespace Doctrine\ORM; +use Doctrine\ORM\Query\Expr; + class QueryBuilder { @@ -21,4 +23,136 @@ class QueryBuilder { } + /** + * @param string $dqlPartName + * @param __doctrine-literal-string|object|list<__doctrine-literal-string>|array{join: array} $dqlPart + * @param bool $append + * + * @return $this + */ + public function add($dqlPartName, $dqlPart, $append = false) + { + + } + + /** + * @param __doctrine-literal-string|null $delete + * @param __doctrine-literal-string|null $alias + * + * @return $this + */ + public function delete($delete = null, $alias = null) + { + + } + + /** + * @param __doctrine-literal-string|null $update + * @param __doctrine-literal-string|null $alias + * + * @return $this + */ + public function update($update = null, $alias = null) + { + + } + + /** + * @param __doctrine-literal-string|class-string $from + * @param __doctrine-literal-string $alias + * @param __doctrine-literal-string|null $indexBy + * + * @return $this + */ + public function from($from, $alias, $indexBy = null) + { + + } + + /** + * @param __doctrine-literal-string|class-string $join + * @param __doctrine-literal-string $alias + * @param Expr\Join::ON|Expr\Join::WITH|null $conditionType + * @param __doctrine-literal-string|Expr\Comparison|Expr\Composite|Expr\Func|null $condition + * @param __doctrine-literal-string|null $indexBy + * + * @return $this + */ + public function innerJoin($join, $alias, $conditionType = null, $condition = null, $indexBy = null) + { + + } + + /** + * @param __doctrine-literal-string|class-string $join + * @param __doctrine-literal-string $alias + * @param Expr\Join::ON|Expr\Join::WITH|null $conditionType + * @param __doctrine-literal-string|Expr\Comparison|Expr\Composite|Expr\Func|null $condition + * @param __doctrine-literal-string|null $indexBy + * + * @return $this + */ + public function leftJoin($join, $alias, $conditionType = null, $condition = null, $indexBy = null) + { + + } + + /** + * @param __doctrine-literal-string|class-string $join + * @param __doctrine-literal-string $alias + * @param Expr\Join::ON|Expr\Join::WITH|null $conditionType + * @param __doctrine-literal-string|Expr\Comparison|Expr\Composite|Expr\Func|null $condition + * @param __doctrine-literal-string|null $indexBy + * + * @return $this + */ + public function join($join, $alias, $conditionType = null, $condition = null, $indexBy = null) + { + + } + + /** + * @return __doctrine-literal-string + */ + public function getRootAlias() + { + + } + + /** + * @return list<__doctrine-literal-string> + */ + public function getRootAliases() + { + + } + + /** + * @return list<__doctrine-literal-string> + */ + public function getAllAlias() + { + + } + + /** + * @param __doctrine-literal-string|object|array $predicates + * @return $this + */ + public function where($predicates) + { + + } + + /** + * @param __doctrine-literal-string|object|array $predicates + * @return $this + */ + public function andWhere($predicates) + { + + } + + + } diff --git a/stubs/Persistence/Mapping/ClassMetadata.stub b/stubs/Persistence/Mapping/ClassMetadata.stub index e16dc685..f64962f4 100644 --- a/stubs/Persistence/Mapping/ClassMetadata.stub +++ b/stubs/Persistence/Mapping/ClassMetadata.stub @@ -15,7 +15,7 @@ interface ClassMetadata public function getName(); /** - * @return ReflectionClass + * @return ReflectionClass */ public function getReflectionClass(); diff --git a/stubs/bleedingEdge/DBAL/Connection.stub b/stubs/bleedingEdge/DBAL/Connection.stub deleted file mode 100644 index 6bb4a01d..00000000 --- a/stubs/bleedingEdge/DBAL/Connection.stub +++ /dev/null @@ -1,64 +0,0 @@ -|array $params Statement parameters - * @param array|array $types Parameter types - * - * @return int|string The number of affected rows. - * - * @throws Exception - */ - public function executeStatement($sql, array $params = [], array $types = []); - - /** - * Executes an, optionally parameterized, SQL query. - * - * If the query is parametrized, a prepared statement is used. - * If an SQLLogger is configured, the execution is logged. - * - * @param literal-string $sql SQL query - * @param list|array $params Query parameters - * @param array|array $types Parameter types - * - * @throws Exception - */ - public function executeQuery( - string $sql, - array $params = [], - $types = [], - ?QueryCacheProfile $qcp = null - ): Result; - - /** - * Executes a caching query. - * - * @param literal-string $sql SQL query - * @param list|array $params Query parameters - * @param array|array $types Parameter types - * - * @throws CacheException - * @throws Exception - */ - public function executeCacheQuery($sql, $params, $types, QueryCacheProfile $qcp): Result; - -} diff --git a/stubs/bleedingEdge/EntityRepository.stub b/stubs/bleedingEdge/EntityRepository.stub deleted file mode 100644 index 6e22e323..00000000 --- a/stubs/bleedingEdge/EntityRepository.stub +++ /dev/null @@ -1,74 +0,0 @@ - - */ -class EntityRepository implements ObjectRepository -{ - - /** @var class-string */ - protected $_entityName; - - /** - * @phpstan-param mixed $id - * @phpstan-param int|null $lockMode - * @phpstan-param int|null $lockVersion - * @phpstan-return TEntityClass|null - */ - public function find($id, $lockMode = null, $lockVersion = null); - - /** - * @phpstan-return list - */ - public function findAll(); - - /** - * @phpstan-param array $criteria - * @phpstan-param array|null $orderBy - * @phpstan-param int|null $limit - * @phpstan-param int|null $offset - * @phpstan-return list - */ - public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null); - - /** - * @phpstan-param array $criteria The criteria. - * @phpstan-param array|null $orderBy - * @phpstan-return TEntityClass|null - */ - public function findOneBy(array $criteria, array $orderBy = null); - - /** - * @phpstan-return class-string - */ - public function getClassName(); - - /** - * @phpstan-return class-string - */ - protected function getEntityName(); - - /** - * @param \Doctrine\Common\Collections\Criteria $criteria - * - * @return \Doctrine\Common\Collections\Collection - * - * @psalm-return \Doctrine\Common\Collections\Collection - */ - public function matching(Criteria $criteria); - - /** - * @param literal-string $alias - * @param literal-string|null $indexBy - * - * @return QueryBuilder - */ - public function createQueryBuilder($alias, $indexBy = null); - -} diff --git a/stubs/bleedingEdge/ORM/QueryBuilder.stub b/stubs/bleedingEdge/ORM/QueryBuilder.stub deleted file mode 100644 index 25e05ee2..00000000 --- a/stubs/bleedingEdge/ORM/QueryBuilder.stub +++ /dev/null @@ -1,156 +0,0 @@ - - */ - public function getQuery() - { - } - - /** - * @param \Doctrine\Common\Collections\ArrayCollection|array $parameters - * @return static - */ - public function setParameters($parameters) - { - - } - - /** - * @param string $dqlPartName - * @param literal-string|object|list|array{join: array} $dqlPart - * @param bool $append - * - * @return $this - */ - public function add($dqlPartName, $dqlPart, $append = false) - { - - } - - /** - * @param literal-string|null $delete - * @param literal-string|null $alias - * - * @return $this - */ - public function delete($delete = null, $alias = null) - { - - } - - /** - * @param literal-string|null $update - * @param literal-string|null $alias - * - * @return $this - */ - public function update($update = null, $alias = null) - { - - } - - /** - * @param literal-string|class-string $from - * @param literal-string $alias - * @param literal-string|null $indexBy - * - * @return $this - */ - public function from($from, $alias, $indexBy = null) - { - - } - - /** - * @param literal-string|class-string $join - * @param literal-string $alias - * @param Expr\Join::ON|Expr\Join::WITH|null $conditionType - * @param literal-string|Expr\Comparison|Expr\Composite|Expr\Func|null $condition - * @param literal-string|null $indexBy - * - * @return $this - */ - public function innerJoin($join, $alias, $conditionType = null, $condition = null, $indexBy = null) - { - - } - - /** - * @param literal-string|class-string $join - * @param literal-string $alias - * @param Expr\Join::ON|Expr\Join::WITH|null $conditionType - * @param literal-string|Expr\Comparison|Expr\Composite|Expr\Func|null $condition - * @param literal-string|null $indexBy - * - * @return $this - */ - public function leftJoin($join, $alias, $conditionType = null, $condition = null, $indexBy = null) - { - - } - - /** - * @param literal-string|class-string $join - * @param literal-string $alias - * @param Expr\Join::ON|Expr\Join::WITH|null $conditionType - * @param literal-string|Expr\Comparison|Expr\Composite|Expr\Func|null $condition - * @param literal-string|null $indexBy - * - * @return $this - */ - public function join($join, $alias, $conditionType = null, $condition = null, $indexBy = null) - { - - } - - /** - * @return literal-string - */ - public function getRootAlias() - { - - } - - /** - * @return list - */ - public function getRootAliases() - { - - } - - /** - * @return list - */ - public function getAllAlias() - { - - } - - /** - * @param literal-string|object|array $predicates - * @return $this - */ - public function where($predicates) - { - - } - - /** - * @param literal-string|object|array $predicates - * @return $this - */ - public function andWhere($predicates) - { - - } - -} diff --git a/tests/BackedEnumStubExtension.php b/tests/BackedEnumStubExtension.php new file mode 100644 index 00000000..80cf36f4 --- /dev/null +++ b/tests/BackedEnumStubExtension.php @@ -0,0 +1,22 @@ += 80100) { + return []; + } + + return [ + __DIR__ . '/../compatibility/BackedEnum.stub', + ]; + } + +} diff --git a/tests/Classes/DoctrineProxyForbiddenClassNamesExtensionTest.php b/tests/Classes/DoctrineProxyForbiddenClassNamesExtensionTest.php index f1f7dd32..5d6800fd 100644 --- a/tests/Classes/DoctrineProxyForbiddenClassNamesExtensionTest.php +++ b/tests/Classes/DoctrineProxyForbiddenClassNamesExtensionTest.php @@ -9,6 +9,7 @@ /** * @extends RuleTestCase + * @runInSeparateProcess */ class DoctrineProxyForbiddenClassNamesExtensionTest extends RuleTestCase { @@ -33,7 +34,7 @@ public function testForbiddenClassNameExtension(): void 20, 'This is most likely unintentional. Did you mean to type \TestPhpStanEntity?', ], - ] + ], ); } diff --git a/tests/Classes/entity-manager.php b/tests/Classes/entity-manager.php index a84bdc54..9c0301d1 100644 --- a/tests/Classes/entity-manager.php +++ b/tests/Classes/entity-manager.php @@ -19,13 +19,13 @@ $metadataDriver = new MappingDriverChain(); $metadataDriver->addDriver(new AnnotationDriver( new AnnotationReader(), - [__DIR__ . '/data'] + [__DIR__ . '/data'], ), 'PHPStan\\Rules\\Doctrine\\ORM\\'); if (PHP_VERSION_ID >= 80100) { $metadataDriver->addDriver( new AttributeDriver([__DIR__ . '/data-attributes']), - 'PHPStan\\Rules\\Doctrine\\ORMAttributes\\' + 'PHPStan\\Rules\\Doctrine\\ORMAttributes\\', ); } @@ -33,7 +33,7 @@ Type::overrideType( 'date', - DateTimeImmutableType::class + DateTimeImmutableType::class, ); return new EntityManager( @@ -41,5 +41,5 @@ 'driver' => 'pdo_sqlite', 'memory' => true, ]), - $config + $config, ); diff --git a/tests/DoctrineIntegration/ODM/DocumentManagerTypeInferenceTest.php b/tests/DoctrineIntegration/ODM/DocumentManagerTypeInferenceTest.php index 8fe0c036..a5ff018d 100644 --- a/tests/DoctrineIntegration/ODM/DocumentManagerTypeInferenceTest.php +++ b/tests/DoctrineIntegration/ODM/DocumentManagerTypeInferenceTest.php @@ -5,18 +5,11 @@ use PHPStan\Testing\TypeInferenceTestCase; use const PHP_VERSION_ID; -class DocumentManagerTypeInferenceTest extends TypeInferenceTestCase +final class DocumentManagerTypeInferenceTest extends TypeInferenceTestCase { - /** - * @return iterable - */ public function dataFileAsserts(): iterable { - if (PHP_VERSION_ID >= 80000) { - return []; - } - yield from $this->gatherAssertTypes(__DIR__ . '/data/documentManagerDynamicReturn.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/documentRepositoryDynamicReturn.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/documentManagerMergeReturn.php'); @@ -33,6 +26,10 @@ public function testFileAsserts( ...$args ): void { + if (PHP_VERSION_ID >= 80000) { + self::markTestSkipped('Test requires PHP 7.'); + } + $this->assertFileAsserts($assertType, $file, ...$args); } diff --git a/tests/DoctrineIntegration/ODM/document-manager.php b/tests/DoctrineIntegration/ODM/document-manager.php index bfdb4141..d104e205 100644 --- a/tests/DoctrineIntegration/ODM/document-manager.php +++ b/tests/DoctrineIntegration/ODM/document-manager.php @@ -16,11 +16,11 @@ $config->setMetadataDriverImpl( new AnnotationDriver( new AnnotationReader(), - [__DIR__ . '/data'] - ) + [__DIR__ . '/data'], + ), ); return DocumentManager::create( null, - $config + $config, ); diff --git a/tests/DoctrineIntegration/ORM/EntityManagerTypeInferenceTest.php b/tests/DoctrineIntegration/ORM/EntityManagerTypeInferenceTest.php index 086a6e0e..52898ad2 100644 --- a/tests/DoctrineIntegration/ORM/EntityManagerTypeInferenceTest.php +++ b/tests/DoctrineIntegration/ORM/EntityManagerTypeInferenceTest.php @@ -9,9 +9,6 @@ class EntityManagerTypeInferenceTest extends TypeInferenceTestCase { - /** - * @return iterable - */ public function dataFileAsserts(): iterable { $ormVersion = InstalledVersions::getVersion('doctrine/orm'); diff --git a/tests/DoctrineIntegration/ORM/EntityManagerWithoutObjectManagerLoaderTypeInferenceTest.php b/tests/DoctrineIntegration/ORM/EntityManagerWithoutObjectManagerLoaderTypeInferenceTest.php index 25308635..56c53fc4 100644 --- a/tests/DoctrineIntegration/ORM/EntityManagerWithoutObjectManagerLoaderTypeInferenceTest.php +++ b/tests/DoctrineIntegration/ORM/EntityManagerWithoutObjectManagerLoaderTypeInferenceTest.php @@ -9,9 +9,6 @@ class EntityManagerWithoutObjectManagerLoaderTypeInferenceTest extends TypeInferenceTestCase { - /** - * @return iterable - */ public function dataFileAsserts(): iterable { $ormVersion = InstalledVersions::getVersion('doctrine/orm'); diff --git a/tests/DoctrineIntegration/ORM/EntityRepositoryDynamicReturnIntegrationTest.php b/tests/DoctrineIntegration/ORM/EntityRepositoryDynamicReturnIntegrationTest.php index e64f096d..de7910fc 100644 --- a/tests/DoctrineIntegration/ORM/EntityRepositoryDynamicReturnIntegrationTest.php +++ b/tests/DoctrineIntegration/ORM/EntityRepositoryDynamicReturnIntegrationTest.php @@ -10,7 +10,7 @@ final class EntityRepositoryDynamicReturnIntegrationTest extends LevelsTestCase /** * @return string[][] */ - public function dataTopics(): array + public static function dataTopics(): array { return [ ['entityRepositoryDynamicReturn'], diff --git a/tests/DoctrineIntegration/ORM/EntityRepositoryWithoutObjectManagerLoaderDynamicReturnIntegrationTest.php b/tests/DoctrineIntegration/ORM/EntityRepositoryWithoutObjectManagerLoaderDynamicReturnIntegrationTest.php index bb2d04c1..ea596a6e 100644 --- a/tests/DoctrineIntegration/ORM/EntityRepositoryWithoutObjectManagerLoaderDynamicReturnIntegrationTest.php +++ b/tests/DoctrineIntegration/ORM/EntityRepositoryWithoutObjectManagerLoaderDynamicReturnIntegrationTest.php @@ -10,7 +10,7 @@ final class EntityRepositoryWithoutObjectManagerLoaderDynamicReturnIntegrationTe /** * @return string[][] */ - public function dataTopics(): array + public static function dataTopics(): array { return [ ['entityRepositoryDynamicReturn'], diff --git a/tests/DoctrineIntegration/ORM/entity-manager.php b/tests/DoctrineIntegration/ORM/entity-manager.php index 23f27436..fb21532a 100644 --- a/tests/DoctrineIntegration/ORM/entity-manager.php +++ b/tests/DoctrineIntegration/ORM/entity-manager.php @@ -15,8 +15,8 @@ $config->setMetadataDriverImpl( new AnnotationDriver( new AnnotationReader(), - [__DIR__ . '/data'] - ) + [__DIR__ . '/data'], + ), ); return new EntityManager( @@ -24,5 +24,5 @@ 'driver' => 'pdo_sqlite', 'memory' => true, ]), - $config + $config, ); diff --git a/tests/DoctrineIntegration/Persistence/ManagerRegistryTypeInferenceTest.php b/tests/DoctrineIntegration/Persistence/ManagerRegistryTypeInferenceTest.php index d35df4a2..a25ff2cf 100644 --- a/tests/DoctrineIntegration/Persistence/ManagerRegistryTypeInferenceTest.php +++ b/tests/DoctrineIntegration/Persistence/ManagerRegistryTypeInferenceTest.php @@ -7,9 +7,6 @@ class ManagerRegistryTypeInferenceTest extends TypeInferenceTestCase { - /** - * @return iterable - */ public function dataFileAsserts(): iterable { yield from $this->gatherAssertTypes(__DIR__ . '/data/managerRegistryRepositoryDynamicReturn.php'); diff --git a/tests/DoctrineIntegration/TypeInferenceTest.php b/tests/DoctrineIntegration/TypeInferenceTest.php index 82a4c236..0382118b 100644 --- a/tests/DoctrineIntegration/TypeInferenceTest.php +++ b/tests/DoctrineIntegration/TypeInferenceTest.php @@ -7,13 +7,11 @@ class TypeInferenceTest extends TypeInferenceTestCase { - /** - * @return iterable - */ public function dataFileAsserts(): iterable { yield from $this->gatherAssertTypes(__DIR__ . '/data/getRepository.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/isEmpty.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/Collection.php'); } /** diff --git a/tests/DoctrineIntegration/data/Collection.php b/tests/DoctrineIntegration/data/Collection.php new file mode 100644 index 00000000..f56e69d7 --- /dev/null +++ b/tests/DoctrineIntegration/data/Collection.php @@ -0,0 +1,56 @@ + */ + private $items; + + public function __construct() + { + /** @var ArrayCollection $numbers */ + $numbers = new ArrayCollection([1, 2, 3]); + + $filteredNumbers = $numbers->filter(function (int $number): bool { + return $number % 2 === 1; + }); + assertType('Doctrine\Common\Collections\ArrayCollection', $filteredNumbers); + + $items = $filteredNumbers->map(static function (int $number): Item { + return new Item(); + }); + assertType('Doctrine\Common\Collections\ArrayCollection', $items); + + $this->items = $items; + } + + public function removeOdd(): void + { + $this->items = $this->items->filter(function (Item $item, int $idx): bool { + return $idx % 2 === 1; + }); + assertType('Doctrine\Common\Collections\Collection', $this->items); + } + + public function __clone() + { + $this->items = $this->items->map( + static function (Item $item): Item { + return clone $item; + } + ); + assertType('Doctrine\Common\Collections\Collection', $this->items); + } + +} + +class Item +{ + +} diff --git a/tests/Platform/Entity/PlatformEntity.php b/tests/Platform/Entity/PlatformEntity.php new file mode 100644 index 00000000..efc1f68d --- /dev/null +++ b/tests/Platform/Entity/PlatformEntity.php @@ -0,0 +1,98 @@ + [], + self::CONFIG_STRINGIFY => [ + PDO::ATTR_STRINGIFY_FETCHES => true, + ], + self::CONFIG_NO_EMULATE => [ + PDO::ATTR_EMULATE_PREPARES => false, + ], + self::CONFIG_STRINGIFY_NO_EMULATE => [ + PDO::ATTR_STRINGIFY_FETCHES => true, + PDO::ATTR_EMULATE_PREPARES => false, + ], + ]; + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/data/config.neon', + ]; + } + + /** + * @param array $data + * @param mixed $mysqlExpectedResult + * @param mixed $sqliteExpectedResult + * @param mixed $pdoPgsqlExpectedResult + * @param mixed $pgsqlExpectedResult + * @param mixed $mssqlExpectedResult + * @param self::STRINGIFY_* $stringify + * + * @dataProvider provideCases + */ + public function testPdoMysqlDefault( + array $data, + string $dqlTemplate, + Type $mysqlExpectedType, + ?Type $sqliteExpectedType, + ?Type $pdoPgsqlExpectedType, + ?Type $pgsqlExpectedType, + ?Type $mssqlExpectedType, + $mysqlExpectedResult, + $sqliteExpectedResult, + $pdoPgsqlExpectedResult, + $pgsqlExpectedResult, + $mssqlExpectedResult, + string $stringify + ): void + { + $this->performDriverTest( + 'pdo_mysql', + self::CONFIG_DEFAULT, + $data, + $dqlTemplate, + (string) $this->dataName(), + PHP_VERSION_ID, + $mysqlExpectedType, + $mysqlExpectedResult, + $stringify, + ); + } + + /** + * @param array $data + * @param mixed $mysqlExpectedResult + * @param mixed $sqliteExpectedResult + * @param mixed $pdoPgsqlExpectedResult + * @param mixed $pgsqlExpectedResult + * @param mixed $mssqlExpectedResult + * @param self::STRINGIFY_* $stringify + * + * @dataProvider provideCases + */ + public function testPdoMysqlStringify( + array $data, + string $dqlTemplate, + Type $mysqlExpectedType, + ?Type $sqliteExpectedType, + ?Type $pdoPgsqlExpectedType, + ?Type $pgsqlExpectedType, + ?Type $mssqlExpectedType, + $mysqlExpectedResult, + $sqliteExpectedResult, + $pdoPgsqlExpectedResult, + $pgsqlExpectedResult, + $mssqlExpectedResult, + string $stringify + ): void + { + $this->performDriverTest( + 'pdo_mysql', + self::CONFIG_STRINGIFY, + $data, + $dqlTemplate, + (string) $this->dataName(), + PHP_VERSION_ID, + $mysqlExpectedType, + $mysqlExpectedResult, + $stringify, + ); + } + + /** + * @param array $data + * @param mixed $mysqlExpectedResult + * @param mixed $sqliteExpectedResult + * @param mixed $pdoPgsqlExpectedResult + * @param mixed $pgsqlExpectedResult + * @param mixed $mssqlExpectedResult + * @param self::STRINGIFY_* $stringify + * + * @dataProvider provideCases + */ + public function testPdoMysqlNoEmulate( + array $data, + string $dqlTemplate, + Type $mysqlExpectedType, + ?Type $sqliteExpectedType, + ?Type $pdoPgsqlExpectedType, + ?Type $pgsqlExpectedType, + ?Type $mssqlExpectedType, + $mysqlExpectedResult, + $sqliteExpectedResult, + $pdoPgsqlExpectedResult, + $pgsqlExpectedResult, + $mssqlExpectedResult, + string $stringify + ): void + { + $this->performDriverTest( + 'pdo_mysql', + self::CONFIG_NO_EMULATE, + $data, + $dqlTemplate, + (string) $this->dataName(), + PHP_VERSION_ID, + $mysqlExpectedType, + $mysqlExpectedResult, + $stringify, + ); + } + + /** + * @param array $data + * @param mixed $mysqlExpectedResult + * @param mixed $sqliteExpectedResult + * @param mixed $pdoPgsqlExpectedResult + * @param mixed $pgsqlExpectedResult + * @param mixed $mssqlExpectedResult + * @param self::STRINGIFY_* $stringify + * + * @dataProvider provideCases + */ + public function testPdoMysqlStringifyNoEmulate( + array $data, + string $dqlTemplate, + Type $mysqlExpectedType, + ?Type $sqliteExpectedType, + ?Type $pdoPgsqlExpectedType, + ?Type $pgsqlExpectedType, + ?Type $mssqlExpectedType, + $mysqlExpectedResult, + $sqliteExpectedResult, + $pdoPgsqlExpectedResult, + $pgsqlExpectedResult, + $mssqlExpectedResult, + string $stringify + ): void + { + $this->performDriverTest( + 'pdo_mysql', + self::CONFIG_STRINGIFY_NO_EMULATE, + $data, + $dqlTemplate, + (string) $this->dataName(), + PHP_VERSION_ID, + $mysqlExpectedType, + $mysqlExpectedResult, + $stringify, + ); + } + + /** + * @param array $data + * @param mixed $mysqlExpectedResult + * @param mixed $sqliteExpectedResult + * @param mixed $pdoPgsqlExpectedResult + * @param mixed $pgsqlExpectedResult + * @param mixed $mssqlExpectedResult + * @param self::STRINGIFY_* $stringify + * + * @dataProvider provideCases + */ + public function testPdoMysqliDefault( + array $data, + string $dqlTemplate, + Type $mysqlExpectedType, + ?Type $sqliteExpectedType, + ?Type $pdoPgsqlExpectedType, + ?Type $pgsqlExpectedType, + ?Type $mssqlExpectedType, + $mysqlExpectedResult, + $sqliteExpectedResult, + $pdoPgsqlExpectedResult, + $pgsqlExpectedResult, + $mssqlExpectedResult, + string $stringify + ): void + { + $this->performDriverTest( + 'mysqli', + self::CONFIG_DEFAULT, + $data, + $dqlTemplate, + (string) $this->dataName(), + PHP_VERSION_ID, + $mysqlExpectedType, + $mysqlExpectedResult, + $stringify, + ); + } + + /** + * @param array $data + * @param mixed $mysqlExpectedResult + * @param mixed $sqliteExpectedResult + * @param mixed $pdoPgsqlExpectedResult + * @param mixed $pgsqlExpectedResult + * @param mixed $mssqlExpectedResult + * @param self::STRINGIFY_* $stringify + * + * @dataProvider provideCases + */ + public function testPdoSqliteDefault( + array $data, + string $dqlTemplate, + Type $mysqlExpectedType, + ?Type $sqliteExpectedType, + ?Type $pdoPgsqlExpectedType, + ?Type $pgsqlExpectedType, + ?Type $mssqlExpectedType, + $mysqlExpectedResult, + $sqliteExpectedResult, + $pdoPgsqlExpectedResult, + $pgsqlExpectedResult, + $mssqlExpectedResult, + string $stringify + ): void + { + $this->performDriverTest( + 'pdo_sqlite', + self::CONFIG_DEFAULT, + $data, + $dqlTemplate, + (string) $this->dataName(), + PHP_VERSION_ID, + $sqliteExpectedType, + $sqliteExpectedResult, + $stringify, + ); + } + + /** + * @param array $data + * @param mixed $mysqlExpectedResult + * @param mixed $sqliteExpectedResult + * @param mixed $pdoPgsqlExpectedResult + * @param mixed $pgsqlExpectedResult + * @param mixed $mssqlExpectedResult + * @param self::STRINGIFY_* $stringify + * + * @dataProvider provideCases + */ + public function testPdoSqliteStringify( + array $data, + string $dqlTemplate, + Type $mysqlExpectedType, + ?Type $sqliteExpectedType, + ?Type $pdoPgsqlExpectedType, + ?Type $pgsqlExpectedType, + ?Type $mssqlExpectedType, + $mysqlExpectedResult, + $sqliteExpectedResult, + $pdoPgsqlExpectedResult, + $pgsqlExpectedResult, + $mssqlExpectedResult, + string $stringify + ): void + { + $this->performDriverTest( + 'pdo_sqlite', + self::CONFIG_STRINGIFY, + $data, + $dqlTemplate, + (string) $this->dataName(), + PHP_VERSION_ID, + $sqliteExpectedType, + $sqliteExpectedResult, + $stringify, + ); + } + + /** + * @param array $data + * @param mixed $mysqlExpectedResult + * @param mixed $sqliteExpectedResult + * @param mixed $pdoPgsqlExpectedResult + * @param mixed $pgsqlExpectedResult + * @param mixed $mssqlExpectedResult + * @param self::STRINGIFY_* $stringify + * + * @dataProvider provideCases + */ + public function testPdoSqlite3( + array $data, + string $dqlTemplate, + Type $mysqlExpectedType, + ?Type $sqliteExpectedType, + ?Type $pdoPgsqlExpectedType, + ?Type $pgsqlExpectedType, + ?Type $mssqlExpectedType, + $mysqlExpectedResult, + $sqliteExpectedResult, + $pdoPgsqlExpectedResult, + $pgsqlExpectedResult, + $mssqlExpectedResult, + string $stringify + ): void + { + $this->performDriverTest( + 'sqlite3', + self::CONFIG_DEFAULT, + $data, + $dqlTemplate, + (string) $this->dataName(), + PHP_VERSION_ID, + $sqliteExpectedType, + $sqliteExpectedResult, + $stringify, + ); + } + + /** + * @param array $data + * @param mixed $mysqlExpectedResult + * @param mixed $sqliteExpectedResult + * @param mixed $pdoPgsqlExpectedResult + * @param mixed $pgsqlExpectedResult + * @param mixed $mssqlExpectedResult + * @param self::STRINGIFY_* $stringify + * + * @dataProvider provideCases + */ + public function testPdoPgsqlDefault( + array $data, + string $dqlTemplate, + Type $mysqlExpectedType, + ?Type $sqliteExpectedType, + ?Type $pdoPgsqlExpectedType, + ?Type $pgsqlExpectedType, + ?Type $mssqlExpectedType, + $mysqlExpectedResult, + $sqliteExpectedResult, + $pdoPgsqlExpectedResult, + $pgsqlExpectedResult, + $mssqlExpectedResult, + string $stringify + ): void + { + $this->performDriverTest( + 'pdo_pgsql', + self::CONFIG_DEFAULT, + $data, + $dqlTemplate, + (string) $this->dataName(), + PHP_VERSION_ID, + $pdoPgsqlExpectedType, + $pdoPgsqlExpectedResult, + $stringify, + ); + } + + /** + * @param array $data + * @param mixed $mysqlExpectedResult + * @param mixed $sqliteExpectedResult + * @param mixed $pdoPgsqlExpectedResult + * @param mixed $pgsqlExpectedResult + * @param mixed $mssqlExpectedResult + * @param self::STRINGIFY_* $stringify + * + * @dataProvider provideCases + */ + public function testPdoPgsqlStringify( + array $data, + string $dqlTemplate, + Type $mysqlExpectedType, + ?Type $sqliteExpectedType, + ?Type $pdoPgsqlExpectedType, + ?Type $pgsqlExpectedType, + ?Type $mssqlExpectedType, + $mysqlExpectedResult, + $sqliteExpectedResult, + $pdoPgsqlExpectedResult, + $pgsqlExpectedResult, + $mssqlExpectedResult, + string $stringify + ): void + { + $this->performDriverTest( + 'pdo_pgsql', + self::CONFIG_STRINGIFY, + $data, + $dqlTemplate, + (string) $this->dataName(), + PHP_VERSION_ID, + $pdoPgsqlExpectedType, + $pdoPgsqlExpectedResult, + $stringify, + ); + } + + /** + * @param array $data + * @param mixed $mysqlExpectedResult + * @param mixed $sqliteExpectedResult + * @param mixed $pdoPgsqlExpectedResult + * @param mixed $pgsqlExpectedResult + * @param mixed $mssqlExpectedResult + * @param self::STRINGIFY_* $stringify + * + * @dataProvider provideCases + */ + public function testPgsql( + array $data, + string $dqlTemplate, + Type $mysqlExpectedType, + ?Type $sqliteExpectedType, + ?Type $pdoPgsqlExpectedType, + ?Type $pgsqlExpectedType, + ?Type $mssqlExpectedType, + $mysqlExpectedResult, + $sqliteExpectedResult, + $pdoPgsqlExpectedResult, + $pgsqlExpectedResult, + $mssqlExpectedResult, + string $stringify + ): void + { + $this->performDriverTest( + 'pgsql', + self::CONFIG_DEFAULT, + $data, + $dqlTemplate, + (string) $this->dataName(), + PHP_VERSION_ID, + $pgsqlExpectedType, + $pgsqlExpectedResult, + $stringify, + ); + } + + /** + * @param array $data + * @param mixed $mysqlExpectedResult + * @param mixed $sqliteExpectedResult + * @param mixed $pdoPgsqlExpectedResult + * @param mixed $pgsqlExpectedResult + * @param mixed $mssqlExpectedResult + * @param self::STRINGIFY_* $stringify + * + * @dataProvider provideCases + */ + public function testUnknownDriver( + array $data, + string $dqlTemplate, + Type $mysqlExpectedType, + ?Type $sqliteExpectedType, + ?Type $pdoPgsqlExpectedType, + ?Type $pgsqlExpectedType, + ?Type $mssqlExpectedType, + $mysqlExpectedResult, + $sqliteExpectedResult, + $pdoPgsqlExpectedResult, + $pgsqlExpectedResult, + $mssqlExpectedResult, + string $stringify + ): void + { + $this->performDriverTest( + 'pdo_mysql', + self::CONFIG_DEFAULT, + $data, + $dqlTemplate, + (string) $this->dataName(), + PHP_VERSION_ID, + $this->determineTypeForUnknownDriverUnknownSetup($mysqlExpectedType, $stringify), + $mysqlExpectedResult, + $stringify, + true, + ); + } + + /** + * @param array $data + * @param mixed $mysqlExpectedResult + * @param mixed $sqliteExpectedResult + * @param mixed $pdoPgsqlExpectedResult + * @param mixed $pgsqlExpectedResult + * @param mixed $mssqlExpectedResult + * @param self::STRINGIFY_* $stringify + * + * @dataProvider provideCases + */ + public function testUnknownDriverStringify( + array $data, + string $dqlTemplate, + Type $mysqlExpectedType, + ?Type $sqliteExpectedType, + ?Type $pdoPgsqlExpectedType, + ?Type $pgsqlExpectedType, + ?Type $mssqlExpectedType, + $mysqlExpectedResult, + $sqliteExpectedResult, + $pdoPgsqlExpectedResult, + $pgsqlExpectedResult, + $mssqlExpectedResult, + string $stringify + ): void + { + $this->performDriverTest( + 'pdo_mysql', + self::CONFIG_STRINGIFY, + $data, + $dqlTemplate, + (string) $this->dataName(), + PHP_VERSION_ID, + $this->determineTypeForUnknownDriverUnknownSetup($mysqlExpectedType, $stringify), + $mysqlExpectedResult, + $stringify, + true, + ); + } + + /** + * @return iterable + */ + public static function provideCases(): iterable + { + yield ' -1' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT -1 FROM %s t', + 'mysqlExpectedType' => new ConstantIntegerType(-1), + 'sqliteExpectedType' => new ConstantIntegerType(-1), + 'pdoPgsqlExpectedType' => new ConstantIntegerType(-1), + 'pgsqlExpectedType' => new ConstantIntegerType(-1), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => -1, + 'sqliteExpectedResult' => -1, + 'pdoPgsqlExpectedResult' => -1, + 'pgsqlExpectedResult' => -1, + 'mssqlExpectedResult' => -1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield ' 1' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT 1 FROM %s t', + 'mysqlExpectedType' => new ConstantIntegerType(1), + 'sqliteExpectedType' => new ConstantIntegerType(1), + 'pdoPgsqlExpectedType' => new ConstantIntegerType(1), + 'pgsqlExpectedType' => new ConstantIntegerType(1), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield ' 1.0' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT 1.0 FROM %s t', + 'mysqlExpectedType' => new ConstantStringType('1.0'), + 'sqliteExpectedType' => new ConstantFloatType(1.0), + 'pdoPgsqlExpectedType' => new ConstantStringType('1.0'), + 'pgsqlExpectedType' => new ConstantStringType('1.0'), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.0', + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1.0', + 'pgsqlExpectedResult' => '1.0', + 'mssqlExpectedResult' => '1.0', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield ' 1.00' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT 1.00 FROM %s t', + 'mysqlExpectedType' => new ConstantStringType('1.00'), + 'sqliteExpectedType' => new ConstantFloatType(1.0), + 'pdoPgsqlExpectedType' => new ConstantStringType('1.00'), + 'pgsqlExpectedType' => new ConstantStringType('1.00'), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.00', + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1.00', + 'pgsqlExpectedResult' => '1.00', + 'mssqlExpectedResult' => '1.00', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield ' 0.1' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT 0.1 FROM %s t', + 'mysqlExpectedType' => new ConstantStringType('0.1'), + 'sqliteExpectedType' => new ConstantFloatType(0.1), + 'pdoPgsqlExpectedType' => new ConstantStringType('0.1'), + 'pgsqlExpectedType' => new ConstantStringType('0.1'), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '0.1', + 'sqliteExpectedResult' => 0.1, + 'pdoPgsqlExpectedResult' => '0.1', + 'pgsqlExpectedResult' => '0.1', + 'mssqlExpectedResult' => '.1', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield ' 0.10' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT 0.10 FROM %s t', + 'mysqlExpectedType' => new ConstantStringType('0.10'), + 'sqliteExpectedType' => new ConstantFloatType(0.1), + 'pdoPgsqlExpectedType' => new ConstantStringType('0.10'), + 'pgsqlExpectedType' => new ConstantStringType('0.10'), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '0.10', + 'sqliteExpectedResult' => 0.1, + 'pdoPgsqlExpectedResult' => '0.10', + 'pgsqlExpectedResult' => '0.10', + 'mssqlExpectedResult' => '.10', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield '0.125e0' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT 0.125e0 FROM %s t', + 'mysqlExpectedType' => new ConstantFloatType(0.125), + 'sqliteExpectedType' => new ConstantFloatType(0.125), + 'pdoPgsqlExpectedType' => new ConstantStringType('0.125'), + 'pgsqlExpectedType' => new ConstantStringType('0.125'), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0.125, + 'sqliteExpectedResult' => 0.125, + 'pdoPgsqlExpectedResult' => '0.125', + 'pgsqlExpectedResult' => '0.125', + 'mssqlExpectedResult' => 0.125, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield ' 1e0' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT 1e0 FROM %s t', + 'mysqlExpectedType' => new ConstantFloatType(1.0), + 'sqliteExpectedType' => new ConstantFloatType(1.0), + 'pdoPgsqlExpectedType' => new ConstantStringType('1'), + 'pgsqlExpectedType' => new ConstantStringType('1'), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1', + 'pgsqlExpectedResult' => '1', + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield " '1'" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT '1' FROM %s t", + 'mysqlExpectedType' => new ConstantStringType('1'), + 'sqliteExpectedType' => new ConstantStringType('1'), + 'pdoPgsqlExpectedType' => new ConstantStringType('1'), + 'pgsqlExpectedType' => new ConstantStringType('1'), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1', + 'sqliteExpectedResult' => '1', + 'pdoPgsqlExpectedResult' => '1', + 'pgsqlExpectedResult' => '1', + 'mssqlExpectedResult' => '1', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield " '1e0'" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT '1e0' FROM %s t", + 'mysqlExpectedType' => new ConstantStringType('1e0'), + 'sqliteExpectedType' => new ConstantStringType('1e0'), + 'pdoPgsqlExpectedType' => new ConstantStringType('1e0'), + 'pgsqlExpectedType' => new ConstantStringType('1e0'), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1e0', + 'sqliteExpectedResult' => '1e0', + 'pdoPgsqlExpectedResult' => '1e0', + 'pgsqlExpectedResult' => '1e0', + 'mssqlExpectedResult' => '1e0', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield '1 + 1' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT (1 + 1) FROM %s t', + 'mysqlExpectedType' => self::int(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 2, + 'sqliteExpectedResult' => 2, + 'pdoPgsqlExpectedResult' => 2, + 'pgsqlExpectedResult' => 2, + 'mssqlExpectedResult' => 2, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "1 + 'foo'" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT (1 + 'foo') FROM %s t", + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => null, // Invalid text representation + 'pgsqlExpectedType' => null, // Invalid text representation + 'mssqlExpectedType' => null, // Conversion failed + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "1 + '1.0'" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT (1 + '1.0') FROM %s t", + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => null, // Invalid text representation + 'pgsqlExpectedType' => null, // Invalid text representation + 'mssqlExpectedType' => null, // Conversion failed + 'mysqlExpectedResult' => 2.0, + 'sqliteExpectedResult' => 2.0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "1 + '1'" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT (1 + '1') FROM %s t", + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 2.0, + 'sqliteExpectedResult' => 2, + 'pdoPgsqlExpectedResult' => 2, + 'pgsqlExpectedResult' => 2, + 'mssqlExpectedResult' => 2, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "1 + '1e0'" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT (1 + '1e0') FROM %s t", + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => null, // Invalid text representation + 'pgsqlExpectedType' => null, // Invalid text representation + 'mssqlExpectedType' => null, // Conversion failed + 'mysqlExpectedResult' => 2.0, + 'sqliteExpectedResult' => 2.0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield '1 + 1 * 1 - 1' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT (1 + 1 * 1 - 1) FROM %s t', + 'mysqlExpectedType' => self::int(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield '1 + 1 * 1 / 1 - 1' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT (1 + 1 * 1 / 1 - 1) FROM %s t', + 'mysqlExpectedType' => self::numericString(true, true), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.0000', + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_int + t.col_int' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_int + t.col_int FROM %s t', + 'mysqlExpectedType' => self::int(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 18, + 'sqliteExpectedResult' => 18, + 'pdoPgsqlExpectedResult' => 18, + 'pgsqlExpectedResult' => 18, + 'mssqlExpectedResult' => 18, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_bigint + t.col_bigint' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_bigint + t.col_bigint FROM %s t', + 'mysqlExpectedType' => self::int(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 4294967296, + 'sqliteExpectedResult' => 4294967296, + 'pdoPgsqlExpectedResult' => 4294967296, + 'pgsqlExpectedResult' => 4294967296, + 'mssqlExpectedResult' => '4294967296', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_int + t.col_float' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_int + t.col_float FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 9.125, + 'sqliteExpectedResult' => 9.125, + 'pdoPgsqlExpectedResult' => 9.125, + 'pgsqlExpectedResult' => 9.125, + 'mssqlExpectedResult' => 9.125, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 't.col_int + t.col_mixed' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_int + t.col_mixed FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => self::mixed(), + 'pgsqlExpectedType' => self::mixed(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 10, + 'sqliteExpectedResult' => 10, + 'pdoPgsqlExpectedResult' => 10, + 'pgsqlExpectedResult' => 10, + 'mssqlExpectedResult' => 10, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_bigint + t.col_float' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_bigint + t.col_float FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 2147483648.125, + 'sqliteExpectedResult' => 2147483648.125, + 'pdoPgsqlExpectedResult' => 2147483648.125, + 'pgsqlExpectedResult' => 2147483648.125, + 'mssqlExpectedResult' => 2147483648.125, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 't.col_bigint + t.col_float (int data)' => [ + 'data' => self::dataAllIntLike(), + 'dqlTemplate' => 'SELECT t.col_bigint + t.col_float FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 2.0, + 'sqliteExpectedResult' => 2.0, + 'pdoPgsqlExpectedResult' => 2.0, + 'pgsqlExpectedResult' => 2.0, + 'mssqlExpectedResult' => 2.0, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 't.col_float + t.col_float' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_float + t.col_float FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0.25, + 'sqliteExpectedResult' => 0.25, + 'pdoPgsqlExpectedResult' => 0.25, + 'pgsqlExpectedResult' => 0.25, + 'mssqlExpectedResult' => 0.25, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 't.col_int + t.col_decimal' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_int + t.col_decimal FROM %s t', + 'mysqlExpectedType' => self::numericString(false, true), + 'sqliteExpectedType' => self::floatOrInt(), + 'pdoPgsqlExpectedType' => self::numericString(false, true), + 'pgsqlExpectedType' => self::numericString(false, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '9.1', + 'sqliteExpectedResult' => 9.1, + 'pdoPgsqlExpectedResult' => '9.1', + 'pgsqlExpectedResult' => '9.1', + 'mssqlExpectedResult' => '9.1', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_int + t.col_decimal (int data)' => [ + 'data' => self::dataAllIntLike(), + 'dqlTemplate' => 'SELECT t.col_int + t.col_decimal FROM %s t', + 'mysqlExpectedType' => self::numericString(false, true), + 'sqliteExpectedType' => self::floatOrInt(), + 'pdoPgsqlExpectedType' => self::numericString(false, true), + 'pgsqlExpectedType' => self::numericString(false, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '2.0', + 'sqliteExpectedResult' => 2, + 'pdoPgsqlExpectedResult' => '2.0', + 'pgsqlExpectedResult' => '2.0', + 'mssqlExpectedResult' => '2.0', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_float + t.col_decimal' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_float + t.col_decimal FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0.225, + 'sqliteExpectedResult' => 0.225, + 'pdoPgsqlExpectedResult' => 0.225, + 'pgsqlExpectedResult' => 0.225, + 'mssqlExpectedResult' => 0.225, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 't.col_float + t.col_decimal (int data)' => [ + 'data' => self::dataAllIntLike(), + 'dqlTemplate' => 'SELECT t.col_float + t.col_decimal FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 2.0, + 'sqliteExpectedResult' => 2.0, + 'pdoPgsqlExpectedResult' => 2.0, + 'pgsqlExpectedResult' => 2.0, + 'mssqlExpectedResult' => 2.0, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 't.col_decimal + t.col_decimal (int data)' => [ + 'data' => self::dataAllIntLike(), + 'dqlTemplate' => 'SELECT t.col_decimal + t.col_decimal FROM %s t', + 'mysqlExpectedType' => self::numericString(false, true), + 'sqliteExpectedType' => self::floatOrInt(), + 'pdoPgsqlExpectedType' => self::numericString(false, true), + 'pgsqlExpectedType' => self::numericString(false, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '2.0', + 'sqliteExpectedResult' => 2, + 'pdoPgsqlExpectedResult' => '2.0', + 'pgsqlExpectedResult' => '2.0', + 'mssqlExpectedResult' => '2.0', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_int + t.col_float + t.col_decimal' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_int + t.col_float + t.col_decimal FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 9.225, + 'sqliteExpectedResult' => 9.225, + 'pdoPgsqlExpectedResult' => 9.225, + 'pgsqlExpectedResult' => 9.225, + 'mssqlExpectedResult' => 9.225, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 't.col_decimal + t.col_decimal' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_decimal + t.col_decimal FROM %s t', + 'mysqlExpectedType' => self::numericString(false, true), + 'sqliteExpectedType' => self::floatOrInt(), + 'pdoPgsqlExpectedType' => self::numericString(false, true), + 'pgsqlExpectedType' => self::numericString(false, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '0.2', + 'sqliteExpectedResult' => 0.2, + 'pdoPgsqlExpectedResult' => '0.2', + 'pgsqlExpectedResult' => '0.2', + 'mssqlExpectedResult' => '.2', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_int + t.col_string' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_int + t.col_string FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Error converting data type + 'mysqlExpectedResult' => 9.0, + 'sqliteExpectedResult' => 9, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_int + t.col_string (int data)' => [ + 'data' => self::dataAllIntLike(), + 'dqlTemplate' => 'SELECT t.col_int + t.col_string FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 2.0, + 'sqliteExpectedResult' => 2, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => 2, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_int + t.col_bool' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_int + t.col_bool FROM %s t', + 'mysqlExpectedType' => self::int(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, + 'mssqlExpectedType' => self::mixed(), // Undefined function + 'mysqlExpectedResult' => 10, + 'sqliteExpectedResult' => 10, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => 10, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_float + t.col_string' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_float + t.col_string FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Error converting data type + 'mysqlExpectedResult' => 0.125, + 'sqliteExpectedResult' => 0.125, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_decimal + t.col_bool' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_decimal + t.col_bool FROM %s t', + 'mysqlExpectedType' => self::numericString(false, true), + 'sqliteExpectedType' => self::floatOrInt(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.1', + 'sqliteExpectedResult' => 1.1, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => '1.1', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_decimal + t.col_string' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_decimal + t.col_string FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Error converting data type + 'mysqlExpectedResult' => 0.1, + 'sqliteExpectedResult' => 0.1, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_int + t.col_int_nullable' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_int + t.col_int_nullable FROM %s t', + 'mysqlExpectedType' => self::intOrNull(), + 'sqliteExpectedType' => self::intOrNull(), + 'pdoPgsqlExpectedType' => self::intOrNull(), + 'pgsqlExpectedType' => self::intOrNull(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_int / t.col_int' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_int / t.col_int FROM %s t', + 'mysqlExpectedType' => self::numericString(true, true), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.0000', + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_bigint / t.col_bigint' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_bigint / t.col_bigint FROM %s t', + 'mysqlExpectedType' => self::numericString(true, true), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.0000', + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => '1', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_int / t.col_float' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_int / t.col_float FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 72.0, + 'sqliteExpectedResult' => 72.0, + 'pdoPgsqlExpectedResult' => 72.0, + 'pgsqlExpectedResult' => 72.0, + 'mssqlExpectedResult' => 72.0, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 't.col_int / t.col_float / t.col_decimal' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_int / t.col_float / t.col_decimal FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 720.0, + 'sqliteExpectedResult' => 720.0, + 'pdoPgsqlExpectedResult' => 720.0, + 'pgsqlExpectedResult' => 720.0, + 'mssqlExpectedResult' => 720.0, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 't.col_bigint / t.col_float' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_bigint / t.col_float FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 17179869184.0, + 'sqliteExpectedResult' => 17179869184.0, + 'pdoPgsqlExpectedResult' => 17179869184.0, + 'pgsqlExpectedResult' => 17179869184.0, + 'mssqlExpectedResult' => 17179869184.0, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 't.col_float / t.col_float' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_float / t.col_float FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => 1.0, + 'pgsqlExpectedResult' => 1.0, + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 't.col_int / t.col_decimal' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_int / t.col_decimal FROM %s t', + 'mysqlExpectedType' => self::numericString(false, true), + 'sqliteExpectedType' => self::floatOrInt(), + 'pdoPgsqlExpectedType' => self::numericString(false, true), + 'pgsqlExpectedType' => self::numericString(false, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '90.0000', + 'sqliteExpectedResult' => 90.0, + 'pdoPgsqlExpectedResult' => '90.0000000000000000', + 'pgsqlExpectedResult' => '90.0000000000000000', + 'mssqlExpectedResult' => '90.000000', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_int / t.col_decimal (int data)' => [ + 'data' => self::dataAllIntLike(), + 'dqlTemplate' => 'SELECT t.col_int / t.col_decimal FROM %s t', + 'mysqlExpectedType' => self::numericString(false, true), + 'sqliteExpectedType' => self::floatOrInt(), + 'pdoPgsqlExpectedType' => self::numericString(false, true), + 'pgsqlExpectedType' => self::numericString(false, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.0000', + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => '1.00000000000000000000', + 'pgsqlExpectedResult' => '1.00000000000000000000', + 'mssqlExpectedResult' => '1.000000', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_float / t.col_decimal' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_float / t.col_decimal FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.25, + 'sqliteExpectedResult' => 1.25, + 'pdoPgsqlExpectedResult' => 1.25, + 'pgsqlExpectedResult' => 1.25, + 'mssqlExpectedResult' => 1.25, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 't.col_decimal / t.col_decimal' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_decimal / t.col_decimal FROM %s t', + 'mysqlExpectedType' => self::numericString(false, true), + 'sqliteExpectedType' => self::floatOrInt(), + 'pdoPgsqlExpectedType' => self::numericString(false, true), + 'pgsqlExpectedType' => self::numericString(false, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.00000', + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1.00000000000000000000', + 'pgsqlExpectedResult' => '1.00000000000000000000', + 'mssqlExpectedResult' => '1.000000', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_decimal / t.col_decimal (int data)' => [ + 'data' => self::dataAllIntLike(), + 'dqlTemplate' => 'SELECT t.col_decimal / t.col_decimal FROM %s t', + 'mysqlExpectedType' => self::numericString(false, true), + 'sqliteExpectedType' => self::floatOrInt(), + 'pdoPgsqlExpectedType' => self::numericString(false, true), + 'pgsqlExpectedType' => self::numericString(false, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.00000', + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => '1.00000000000000000000', + 'pgsqlExpectedResult' => '1.00000000000000000000', + 'mssqlExpectedResult' => '1.000000', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_decimal / t.col_mixed' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_decimal / t.col_mixed FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => self::mixed(), + 'pgsqlExpectedType' => self::mixed(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '0.10000', + 'sqliteExpectedResult' => 0.1, + 'pdoPgsqlExpectedResult' => '0.10000000000000000000', + 'pgsqlExpectedResult' => '0.10000000000000000000', + 'mssqlExpectedResult' => '.100000000000', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_int / t.col_string' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_int / t.col_string FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Conversion failed + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_int / t.col_string (int data)' => [ + 'data' => self::dataAllIntLike(), + 'dqlTemplate' => 'SELECT t.col_int / t.col_string FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_string / t.col_int' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_string / t.col_int FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Conversion failed + 'mysqlExpectedResult' => 0.0, + 'sqliteExpectedResult' => 0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_int / t.col_bool' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_int / t.col_bool FROM %s t', + 'mysqlExpectedType' => self::numericString(true, true), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '9.0000', + 'sqliteExpectedResult' => 9, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => 9, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_float / t.col_string' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_float / t.col_string FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Error converting data type + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_string / t.col_float' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_string / t.col_float FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Error converting data type + 'mysqlExpectedResult' => 0.0, + 'sqliteExpectedResult' => 0.0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_decimal / t.col_bool' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_decimal / t.col_bool FROM %s t', + 'mysqlExpectedType' => self::numericString(false, true), + 'sqliteExpectedType' => self::floatOrInt(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '0.10000', + 'sqliteExpectedResult' => 0.1, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => '.100000', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_decimal / t.col_bool (int data)' => [ + 'data' => self::dataAllIntLike(), + 'dqlTemplate' => 'SELECT t.col_decimal / t.col_bool FROM %s t', + 'mysqlExpectedType' => self::numericString(false, true), + 'sqliteExpectedType' => self::floatOrInt(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.00000', + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => '1.000000', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_decimal / t.col_string' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_decimal / t.col_string FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Error converting data type + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_string / t.col_decimal' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_string / t.col_decimal FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Error converting data type + 'mysqlExpectedResult' => 0.0, + 'sqliteExpectedResult' => 0.0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 't.col_int / t.col_int_nullable' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_int / t.col_int_nullable FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(true, true), + 'sqliteExpectedType' => self::intOrNull(), + 'pdoPgsqlExpectedType' => self::intOrNull(), + 'pgsqlExpectedType' => self::intOrNull(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield '1 - 1' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT (1 - 1) FROM %s t', + 'mysqlExpectedType' => self::int(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0, + 'sqliteExpectedResult' => 0, + 'pdoPgsqlExpectedResult' => 0, + 'pgsqlExpectedResult' => 0, + 'mssqlExpectedResult' => 0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield '1 * 1' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT (1 * 1) FROM %s t', + 'mysqlExpectedType' => self::int(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "1 * '1'" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT (1 * '1') FROM %s t", + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "1 * '1.0'" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT (1 * '1.0') FROM %s t", + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => null, // Invalid text representation + 'pgsqlExpectedType' => null, // Invalid text representation + 'mssqlExpectedType' => null, // Conversion failed + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield '1 / 1' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT (1 / 1) FROM %s t', + 'mysqlExpectedType' => self::numericString(true, true), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.0000', + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield '1 / 1.0' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT (1 / 1.0) FROM %s t', + 'mysqlExpectedType' => self::numericString(true, true), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::numericString(true, true), + 'pgsqlExpectedType' => self::numericString(true, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.0000', + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1.00000000000000000000', + 'pgsqlExpectedResult' => '1.00000000000000000000', + 'mssqlExpectedResult' => '1.000000', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield '1 / 1e0' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT (1 / 1e0) FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::numericString(true, true), + 'pgsqlExpectedType' => self::numericString(true, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1.00000000000000000000', + 'pgsqlExpectedResult' => '1.00000000000000000000', + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "'foo' / 1" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT ('foo' / 1) FROM %s t", + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => null, // Invalid text representation + 'pgsqlExpectedType' => null, // Invalid text representation + 'mssqlExpectedType' => null, // Conversion failed + 'mysqlExpectedResult' => 0.0, + 'sqliteExpectedResult' => 0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "1 / 'foo'" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT (1 / 'foo') FROM %s t", + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => null, // Invalid text representation + 'pgsqlExpectedType' => null, // Invalid text representation + 'mssqlExpectedType' => null, // Conversion failed + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "1 / '1'" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT (1 / '1') FROM %s t", + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "'1' / 1" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT ('1' / 1) FROM %s t", + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "1 / '1.0'" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT (1 / '1.0') FROM %s t", + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => null, // Invalid text representation + 'pgsqlExpectedType' => null, // Invalid text representation + 'mssqlExpectedType' => null, // Conversion failed + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield '2147483648 ' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT 2147483648 FROM %s t', + 'mysqlExpectedType' => new ConstantIntegerType(2147483648), + 'sqliteExpectedType' => new ConstantIntegerType(2147483648), + 'pdoPgsqlExpectedType' => new ConstantIntegerType(2147483648), + 'pgsqlExpectedType' => new ConstantIntegerType(2147483648), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 2147483648, + 'sqliteExpectedResult' => 2147483648, + 'pdoPgsqlExpectedResult' => 2147483648, + 'pgsqlExpectedResult' => 2147483648, + 'mssqlExpectedResult' => '2147483648', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "''" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT \'\' FROM %s t', + 'mysqlExpectedType' => new ConstantStringType(''), + 'sqliteExpectedType' => new ConstantStringType(''), + 'pdoPgsqlExpectedType' => new ConstantStringType(''), + 'pgsqlExpectedType' => new ConstantStringType(''), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '', + 'sqliteExpectedResult' => '', + 'pdoPgsqlExpectedResult' => '', + 'pgsqlExpectedResult' => '', + 'mssqlExpectedResult' => '', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield '(TRUE)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT (TRUE) FROM %s t', + 'mysqlExpectedType' => new ConstantIntegerType(1), + 'sqliteExpectedType' => new ConstantIntegerType(1), + 'pdoPgsqlExpectedType' => new ConstantBooleanType(true), + 'pgsqlExpectedType' => new ConstantBooleanType(true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => true, + 'pgsqlExpectedResult' => true, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_PG_BOOL, + ]; + + yield '(FALSE)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT (FALSE) FROM %s t', + 'mysqlExpectedType' => new ConstantIntegerType(0), + 'sqliteExpectedType' => new ConstantIntegerType(0), + 'pdoPgsqlExpectedType' => new ConstantBooleanType(false), + 'pgsqlExpectedType' => new ConstantBooleanType(false), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0, + 'sqliteExpectedResult' => 0, + 'pdoPgsqlExpectedResult' => false, + 'pgsqlExpectedResult' => false, + 'mssqlExpectedResult' => 0, + 'stringify' => self::STRINGIFY_PG_BOOL, + ]; + + yield 't.col_bool' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_bool FROM %s t', + 'mysqlExpectedType' => self::bool(), + 'sqliteExpectedType' => self::bool(), + 'pdoPgsqlExpectedType' => self::bool(), + 'pgsqlExpectedType' => self::bool(), + 'mssqlExpectedType' => self::bool(), + 'mysqlExpectedResult' => true, + 'sqliteExpectedResult' => true, + 'pdoPgsqlExpectedResult' => true, + 'pgsqlExpectedResult' => true, + 'mssqlExpectedResult' => true, + 'stringify' => self::STRINGIFY_NONE, + ]; + + yield 't.col_bool_nullable' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_bool_nullable FROM %s t', + 'mysqlExpectedType' => self::boolOrNull(), + 'sqliteExpectedType' => self::boolOrNull(), + 'pdoPgsqlExpectedType' => self::boolOrNull(), + 'pgsqlExpectedType' => self::boolOrNull(), + 'mssqlExpectedType' => self::boolOrNull(), + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_NONE, + ]; + + yield 'COALESCE(t.col_bool, t.col_bool)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(t.col_bool, t.col_bool) FROM %s t', + 'mysqlExpectedType' => self::int(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::bool(), + 'pgsqlExpectedType' => self::bool(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => true, + 'pgsqlExpectedResult' => true, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_PG_BOOL, + ]; + + yield 'COALESCE(t.col_decimal, t.col_decimal) + int data' => [ + 'data' => self::dataAllIntLike(), + 'dqlTemplate' => 'SELECT COALESCE(t.col_decimal, t.col_decimal) FROM %s t', + 'mysqlExpectedType' => self::numericString(false, true), + 'sqliteExpectedType' => self::floatOrInt(), + 'pdoPgsqlExpectedType' => self::numericString(false, true), + 'pgsqlExpectedType' => self::numericString(false, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.0', + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => '1.0', + 'pgsqlExpectedResult' => '1.0', + 'mssqlExpectedResult' => '1.0', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(t.col_float, t.col_float)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(t.col_float, t.col_float) FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0.125, + 'sqliteExpectedResult' => 0.125, + 'pdoPgsqlExpectedResult' => 0.125, + 'pgsqlExpectedResult' => 0.125, + 'mssqlExpectedResult' => 0.125, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 'COALESCE(t.col_float, t.col_float) + int data' => [ + 'data' => self::dataAllIntLike(), + 'dqlTemplate' => 'SELECT COALESCE(t.col_float, t.col_float) FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => 1.0, + 'pgsqlExpectedResult' => 1.0, + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 't.col_decimal' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_decimal FROM %s t', + 'mysqlExpectedType' => self::numericString(false, true), + 'sqliteExpectedType' => self::numericString(false, true), + 'pdoPgsqlExpectedType' => self::numericString(false, true), + 'pgsqlExpectedType' => self::numericString(false, true), + 'mssqlExpectedType' => self::numericString(false, true), + 'mysqlExpectedResult' => '0.1', + 'sqliteExpectedResult' => '0.1', + 'pdoPgsqlExpectedResult' => '0.1', + 'pgsqlExpectedResult' => '0.1', + 'mssqlExpectedResult' => '.1', + 'stringify' => self::STRINGIFY_NONE, + ]; + + yield 't.col_int' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_int FROM %s t', + 'mysqlExpectedType' => self::int(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::int(), + 'mysqlExpectedResult' => 9, + 'sqliteExpectedResult' => 9, + 'pdoPgsqlExpectedResult' => 9, + 'pgsqlExpectedResult' => 9, + 'mssqlExpectedResult' => 9, + 'stringify' => self::STRINGIFY_NONE, + ]; + + yield 't.col_bigint' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_bigint FROM %s t', + 'mysqlExpectedType' => self::hasDbal4() ? self::int() : self::numericString(true, true), + 'sqliteExpectedType' => self::hasDbal4() ? self::int() : self::numericString(true, true), + 'pdoPgsqlExpectedType' => self::hasDbal4() ? self::int() : self::numericString(true, true), + 'pgsqlExpectedType' => self::hasDbal4() ? self::int() : self::numericString(true, true), + 'mssqlExpectedType' => self::hasDbal4() ? self::int() : self::numericString(true, true), + 'mysqlExpectedResult' => self::hasDbal4() ? 2147483648 : '2147483648', + 'sqliteExpectedResult' => self::hasDbal4() ? 2147483648 : '2147483648', + 'pdoPgsqlExpectedResult' => self::hasDbal4() ? 2147483648 : '2147483648', + 'pgsqlExpectedResult' => self::hasDbal4() ? 2147483648 : '2147483648', + 'mssqlExpectedResult' => self::hasDbal4() ? 2147483648 : '2147483648', + 'stringify' => self::STRINGIFY_NONE, + ]; + + yield 't.col_float' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_float FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::float(), + 'mysqlExpectedResult' => 0.125, + 'sqliteExpectedResult' => 0.125, + 'pdoPgsqlExpectedResult' => 0.125, + 'pgsqlExpectedResult' => 0.125, + 'mssqlExpectedResult' => 0.125, + 'stringify' => self::STRINGIFY_NONE, + ]; + + yield 'AVG(t.col_float)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT AVG(t.col_float) FROM %s t', + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::floatOrNull(), + 'pgsqlExpectedType' => self::floatOrNull(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0.125, + 'sqliteExpectedResult' => 0.125, + 'pdoPgsqlExpectedResult' => 0.125, + 'pgsqlExpectedResult' => 0.125, + 'mssqlExpectedResult' => 0.125, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 'AVG(t.col_float) + no data' => [ + 'data' => self::dataNone(), + 'dqlTemplate' => 'SELECT AVG(t.col_float) FROM %s t', + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::floatOrNull(), + 'pgsqlExpectedType' => self::floatOrNull(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 'AVG(t.col_float) + GROUP BY' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT AVG(t.col_float) FROM %s t GROUP BY t.col_int', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0.125, + 'sqliteExpectedResult' => 0.125, + 'pdoPgsqlExpectedResult' => 0.125, + 'pgsqlExpectedResult' => 0.125, + 'mssqlExpectedResult' => 0.125, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 'AVG(t.col_float_nullable) + GROUP BY' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT AVG(t.col_float_nullable) FROM %s t GROUP BY t.col_int', + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::floatOrNull(), + 'pgsqlExpectedType' => self::floatOrNull(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 'AVG(t.col_decimal)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT AVG(t.col_decimal) FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(false, true), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::numericStringOrNull(false, true), + 'pgsqlExpectedType' => self::numericStringOrNull(false, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '0.10000', + 'sqliteExpectedResult' => 0.1, + 'pdoPgsqlExpectedResult' => '0.10000000000000000000', + 'pgsqlExpectedResult' => '0.10000000000000000000', + 'mssqlExpectedResult' => '.100000', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'AVG(t.col_decimal) + int data' => [ + 'data' => self::dataAllIntLike(), + 'dqlTemplate' => 'SELECT AVG(t.col_decimal) FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(false, true), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::numericStringOrNull(false, true), + 'pgsqlExpectedType' => self::numericStringOrNull(false, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.00000', + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1.00000000000000000000', + 'pgsqlExpectedResult' => '1.00000000000000000000', + 'mssqlExpectedResult' => '1.000000', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'AVG(t.col_mixed)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT AVG(t.col_mixed) FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::floatOrNull(), // always float|null, see https://www.sqlite.org/lang_aggfunc.html + 'pdoPgsqlExpectedType' => self::mixed(), + 'pgsqlExpectedType' => self::mixed(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.0000', + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1.00000000000000000000', + 'pgsqlExpectedResult' => '1.00000000000000000000', + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'AVG(t.col_int)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT AVG(t.col_int) FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(true, true), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::numericStringOrNull(true, true), + 'pgsqlExpectedType' => self::numericStringOrNull(true, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '9.0000', + 'sqliteExpectedResult' => 9.0, + 'pdoPgsqlExpectedResult' => '9.0000000000000000', + 'pgsqlExpectedResult' => '9.0000000000000000', + 'mssqlExpectedResult' => 9, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'AVG(t.col_bool)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT AVG(t.col_bool) FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(true, true), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // perand data type bit is invalid for avg operator. + 'mysqlExpectedResult' => '1.0000', + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'AVG(t.col_string)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT AVG(t.col_string) FROM %s t', + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Operand data type nvarchar is invalid for avg operator + 'mysqlExpectedResult' => 0.0, + 'sqliteExpectedResult' => 0.0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'AVG(1)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT AVG(1) FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(true, true), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::numericStringOrNull(true, true), + 'pgsqlExpectedType' => self::numericStringOrNull(true, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.0000', + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1.00000000000000000000', + 'pgsqlExpectedResult' => '1.00000000000000000000', + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "AVG('1')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT AVG('1') FROM %s t", + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Operand data type is invalid + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "AVG('1.0')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT AVG('1.0') FROM %s t", + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Operand data type is invalid + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "AVG('1e0')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT AVG('1e0') FROM %s t", + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Operand data type is invalid + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "AVG('foo')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT AVG('foo') FROM %s t", + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Operand data type is invalid + 'mysqlExpectedResult' => 0.0, + 'sqliteExpectedResult' => 0.0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'AVG(1) + GROUP BY' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT AVG(1) FROM %s t GROUP BY t.col_int', + 'mysqlExpectedType' => self::numericString(true, true), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::numericString(true, true), + 'pgsqlExpectedType' => self::numericString(true, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.0000', + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1.00000000000000000000', + 'pgsqlExpectedResult' => '1.00000000000000000000', + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'AVG(1.0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT AVG(1.0) FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(true, true), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::numericStringOrNull(true, true), + 'pgsqlExpectedType' => self::numericStringOrNull(true, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.00000', + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1.00000000000000000000', + 'pgsqlExpectedResult' => '1.00000000000000000000', + 'mssqlExpectedResult' => '1.000000', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'AVG(1e0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT AVG(1.0) FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(true, true), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::numericStringOrNull(true, true), + 'pgsqlExpectedType' => self::numericStringOrNull(true, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.00000', + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1.00000000000000000000', + 'pgsqlExpectedResult' => '1.00000000000000000000', + 'mssqlExpectedResult' => '1.000000', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'AVG(t.col_bigint)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT AVG(t.col_bigint) FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(true, true), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::numericStringOrNull(true, true), + 'pgsqlExpectedType' => self::numericStringOrNull(true, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '2147483648.0000', + 'sqliteExpectedResult' => 2147483648.0, + 'pdoPgsqlExpectedResult' => '2147483648.00000000', + 'pgsqlExpectedResult' => '2147483648.00000000', + 'mssqlExpectedResult' => '2147483648', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'SUM(t.col_float)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT SUM(t.col_float) FROM %s t', + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::floatOrNull(), + 'pgsqlExpectedType' => self::floatOrNull(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0.125, + 'sqliteExpectedResult' => 0.125, + 'pdoPgsqlExpectedResult' => 0.125, + 'pgsqlExpectedResult' => 0.125, + 'mssqlExpectedResult' => 0.125, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 'SUM(t.col_float) + no data' => [ + 'data' => self::dataNone(), + 'dqlTemplate' => 'SELECT SUM(t.col_float) FROM %s t', + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::floatOrNull(), + 'pgsqlExpectedType' => self::floatOrNull(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 'SUM(t.col_float) + GROUP BY' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT SUM(t.col_float) FROM %s t GROUP BY t.col_int', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0.125, + 'sqliteExpectedResult' => 0.125, + 'pdoPgsqlExpectedResult' => 0.125, + 'pgsqlExpectedResult' => 0.125, + 'mssqlExpectedResult' => 0.125, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield '1 + -(CASE WHEN MIN(t.col_float) = 0 THEN SUM(t.col_float) ELSE 0 END)' => [ // agg function (causing null) deeply inside AST + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT 1 + -(CASE WHEN MIN(t.col_float) = 0 THEN SUM(t.col_float) ELSE 0 END) FROM %s t', + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrIntOrNull(), + 'pdoPgsqlExpectedType' => self::floatOrNull(), + 'pgsqlExpectedType' => self::floatOrNull(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1.0, + 'pgsqlExpectedResult' => 1.0, + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 'SUM(t.col_decimal)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT SUM(t.col_decimal) FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(false, true), + 'sqliteExpectedType' => self::floatOrIntOrNull(), + 'pdoPgsqlExpectedType' => self::numericStringOrNull(false, true), + 'pgsqlExpectedType' => self::numericStringOrNull(false, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '0.1', + 'sqliteExpectedResult' => 0.1, + 'pdoPgsqlExpectedResult' => '0.1', + 'pgsqlExpectedResult' => '0.1', + 'mssqlExpectedResult' => '.1', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'SUM(t.col_decimal) + int data' => [ + 'data' => self::dataAllIntLike(), + 'dqlTemplate' => 'SELECT SUM(t.col_decimal) FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(false, true), + 'sqliteExpectedType' => self::floatOrIntOrNull(), + 'pdoPgsqlExpectedType' => self::numericStringOrNull(false, true), + 'pgsqlExpectedType' => self::numericStringOrNull(false, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.0', + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => '1.0', + 'pgsqlExpectedResult' => '1.0', + 'mssqlExpectedResult' => '1.0', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'SUM(t.col_int)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT SUM(t.col_int) FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(true, true), + 'sqliteExpectedType' => self::intOrNull(), + 'pdoPgsqlExpectedType' => TypeCombinator::union(self::numericStringOrNull(true, true), self::intOrNull()), + 'pgsqlExpectedType' => TypeCombinator::union(self::numericStringOrNull(true, true), self::intOrNull()), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '9', + 'sqliteExpectedResult' => 9, + 'pdoPgsqlExpectedResult' => 9, + 'pgsqlExpectedResult' => 9, + 'mssqlExpectedResult' => 9, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield '-SUM(t.col_int)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT -SUM(t.col_int) FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(true, true), + 'sqliteExpectedType' => self::intOrNull(), + 'pdoPgsqlExpectedType' => TypeCombinator::union(self::numericStringOrNull(true, true), self::intOrNull()), + 'pgsqlExpectedType' => TypeCombinator::union(self::numericStringOrNull(true, true), self::intOrNull()), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '-9', + 'sqliteExpectedResult' => -9, + 'pdoPgsqlExpectedResult' => -9, + 'pgsqlExpectedResult' => -9, + 'mssqlExpectedResult' => -9, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield '-SUM(t.col_int) + no data' => [ + 'data' => self::dataNone(), + 'dqlTemplate' => 'SELECT -SUM(t.col_int) FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(true, true), + 'sqliteExpectedType' => self::intOrNull(), + 'pdoPgsqlExpectedType' => TypeCombinator::union(self::numericStringOrNull(true, true), self::intOrNull()), + 'pgsqlExpectedType' => TypeCombinator::union(self::numericStringOrNull(true, true), self::intOrNull()), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'SUM(t.col_mixed)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT SUM(t.col_mixed) FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => self::mixed(), + 'pgsqlExpectedType' => self::mixed(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1', + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'SUM(t.col_bool)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT SUM(t.col_bool) FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(true, true), + 'sqliteExpectedType' => self::intOrNull(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Operand data type is invalid + 'mysqlExpectedResult' => '1', + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'SUM(t.col_string)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT SUM(t.col_string) FROM %s t', + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Operand data type is invalid + 'mysqlExpectedResult' => 0.0, + 'sqliteExpectedResult' => 0.0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "SUM('foo')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT SUM('foo') FROM %s t", + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Operand data type is invalid + 'mysqlExpectedResult' => 0.0, + 'sqliteExpectedResult' => 0.0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "SUM('1')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT SUM('1') FROM %s t", + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::intOrNull(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Operand data type is invalid + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "SUM('1.0')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT SUM('1.0') FROM %s t", + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Operand data type is invalid + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "SUM('1.1')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT SUM('1.1') FROM %s t", + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Operand data type is invalid + 'mysqlExpectedResult' => 1.1, + 'sqliteExpectedResult' => 1.1, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'SUM(1)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT SUM(1) FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(true, true), + 'sqliteExpectedType' => self::intOrNull(), + 'pdoPgsqlExpectedType' => TypeCombinator::union(self::numericStringOrNull(true, true), self::intOrNull()), + 'pgsqlExpectedType' => TypeCombinator::union(self::numericStringOrNull(true, true), self::intOrNull()), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1', + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'SUM(1) + GROUP BY' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT SUM(1) FROM %s t GROUP BY t.col_int', + 'mysqlExpectedType' => self::numericString(true, true), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => TypeCombinator::union(self::numericString(true, true), self::int()), + 'pgsqlExpectedType' => TypeCombinator::union(self::numericString(true, true), self::int()), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1', + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'SUM(1.0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT SUM(1.0) FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(true, true), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::numericStringOrNull(true, true), + 'pgsqlExpectedType' => self::numericStringOrNull(true, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.0', + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1.0', + 'pgsqlExpectedResult' => '1.0', + 'mssqlExpectedResult' => '1.0', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'SUM(1e0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT SUM(1e0) FROM %s t', + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::numericStringOrNull(true, true), + 'pgsqlExpectedType' => self::numericStringOrNull(true, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1', + 'pgsqlExpectedResult' => '1', + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'SUM(t.col_bigint)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT SUM(t.col_bigint) FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(true, true), + 'sqliteExpectedType' => self::intOrNull(), + 'pdoPgsqlExpectedType' => TypeCombinator::union(self::numericStringOrNull(true, true), self::intOrNull()), + 'pgsqlExpectedType' => TypeCombinator::union(self::numericStringOrNull(true, true), self::intOrNull()), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '2147483648', + 'sqliteExpectedResult' => 2147483648, + 'pdoPgsqlExpectedResult' => '2147483648', + 'pgsqlExpectedResult' => '2147483648', + 'mssqlExpectedResult' => '2147483648', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MAX(t.col_float)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MAX(t.col_float) FROM %s t', + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::floatOrNull(), + 'pgsqlExpectedType' => self::floatOrNull(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0.125, + 'sqliteExpectedResult' => 0.125, + 'pdoPgsqlExpectedResult' => 0.125, + 'pgsqlExpectedResult' => 0.125, + 'mssqlExpectedResult' => 0.125, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 'MAX(t.col_float) + no data' => [ + 'data' => self::dataNone(), + 'dqlTemplate' => 'SELECT MAX(t.col_float) FROM %s t', + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::floatOrNull(), + 'pgsqlExpectedType' => self::floatOrNull(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 'MAX(t.col_float) + GROUP BY' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MAX(t.col_float) FROM %s t GROUP BY t.col_int', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0.125, + 'sqliteExpectedResult' => 0.125, + 'pdoPgsqlExpectedResult' => 0.125, + 'pgsqlExpectedResult' => 0.125, + 'mssqlExpectedResult' => 0.125, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 'MAX(t.col_decimal)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MAX(t.col_decimal) FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(false, true), + 'sqliteExpectedType' => self::floatOrIntOrNull(), + 'pdoPgsqlExpectedType' => self::numericStringOrNull(false, true), + 'pgsqlExpectedType' => self::numericStringOrNull(false, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '0.1', + 'sqliteExpectedResult' => 0.1, + 'pdoPgsqlExpectedResult' => '0.1', + 'pgsqlExpectedResult' => '0.1', + 'mssqlExpectedResult' => '.1', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MAX(t.col_decimal) + int data' => [ + 'data' => self::dataAllIntLike(), + 'dqlTemplate' => 'SELECT MAX(t.col_decimal) FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(false, true), + 'sqliteExpectedType' => self::floatOrIntOrNull(), + 'pdoPgsqlExpectedType' => self::numericStringOrNull(false, true), + 'pgsqlExpectedType' => self::numericStringOrNull(false, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.0', + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => '1.0', + 'pgsqlExpectedResult' => '1.0', + 'mssqlExpectedResult' => '1.0', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MAX(t.col_int)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MAX(t.col_int) FROM %s t', + 'mysqlExpectedType' => self::intOrNull(), + 'sqliteExpectedType' => self::intOrNull(), + 'pdoPgsqlExpectedType' => self::intOrNull(), + 'pgsqlExpectedType' => self::intOrNull(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 9, + 'sqliteExpectedResult' => 9, + 'pdoPgsqlExpectedResult' => 9, + 'pgsqlExpectedResult' => 9, + 'mssqlExpectedResult' => 9, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MAX(t.col_mixed)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MAX(t.col_mixed) FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => self::mixed(), + 'pgsqlExpectedType' => self::mixed(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MAX(t.col_bool)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MAX(t.col_bool) FROM %s t', + 'mysqlExpectedType' => self::intOrNull(), + 'sqliteExpectedType' => self::intOrNull(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Operand data type is invalid + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MAX(t.col_string)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MAX(t.col_string) FROM %s t', + 'mysqlExpectedType' => self::stringOrNull(), + 'sqliteExpectedType' => self::stringOrNull(), + 'pdoPgsqlExpectedType' => self::stringOrNull(), + 'pgsqlExpectedType' => self::stringOrNull(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 'foobar', + 'sqliteExpectedResult' => 'foobar', + 'pdoPgsqlExpectedResult' => 'foobar', + 'pgsqlExpectedResult' => 'foobar', + 'mssqlExpectedResult' => 'foobar', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "MAX('foobar')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT MAX('foobar') FROM %s t", + 'mysqlExpectedType' => TypeCombinator::addNull(self::string()), + 'sqliteExpectedType' => TypeCombinator::addNull(self::string()), + 'pdoPgsqlExpectedType' => TypeCombinator::addNull(self::string()), + 'pgsqlExpectedType' => TypeCombinator::addNull(self::string()), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 'foobar', + 'sqliteExpectedResult' => 'foobar', + 'pdoPgsqlExpectedResult' => 'foobar', + 'pgsqlExpectedResult' => 'foobar', + 'mssqlExpectedResult' => 'foobar', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "MAX('1')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT MAX('1') FROM %s t", + 'mysqlExpectedType' => self::numericStringOrNull(true, true), + 'sqliteExpectedType' => self::numericStringOrNull(true, true), + 'pdoPgsqlExpectedType' => self::numericStringOrNull(true, true), + 'pgsqlExpectedType' => self::numericStringOrNull(true, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1', + 'sqliteExpectedResult' => '1', + 'pdoPgsqlExpectedResult' => '1', + 'pgsqlExpectedResult' => '1', + 'mssqlExpectedResult' => '1', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "MAX('1.0')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT MAX('1.0') FROM %s t", + 'mysqlExpectedType' => self::numericStringOrNull(true, true), + 'sqliteExpectedType' => self::numericStringOrNull(true, true), + 'pdoPgsqlExpectedType' => self::numericStringOrNull(true, true), + 'pgsqlExpectedType' => self::numericStringOrNull(true, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.0', + 'sqliteExpectedResult' => '1.0', + 'pdoPgsqlExpectedResult' => '1.0', + 'pgsqlExpectedResult' => '1.0', + 'mssqlExpectedResult' => '1.0', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MAX(1)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MAX(1) FROM %s t', + 'mysqlExpectedType' => self::intOrNull(), + 'sqliteExpectedType' => self::intOrNull(), + 'pdoPgsqlExpectedType' => self::intOrNull(), + 'pgsqlExpectedType' => self::intOrNull(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MAX(1) + GROUP BY' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MAX(1) FROM %s t GROUP BY t.col_int', + 'mysqlExpectedType' => self::int(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MAX(1.0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MAX(1.0) FROM %s t', + 'mysqlExpectedType' => self::numericStringOrNull(true, true), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::numericStringOrNull(true, true), + 'pgsqlExpectedType' => self::numericStringOrNull(true, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.0', + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1.0', + 'pgsqlExpectedResult' => '1.0', + 'mssqlExpectedResult' => '1.0', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MAX(1e0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MAX(1e0) FROM %s t', + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::numericStringOrNull(true, true), + 'pgsqlExpectedType' => self::numericStringOrNull(true, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1', + 'pgsqlExpectedResult' => '1', + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MAX(t.col_bigint)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MAX(t.col_bigint) FROM %s t', + 'mysqlExpectedType' => self::intOrNull(), + 'sqliteExpectedType' => self::intOrNull(), + 'pdoPgsqlExpectedType' => self::intOrNull(), + 'pgsqlExpectedType' => self::intOrNull(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 2147483648, + 'sqliteExpectedResult' => 2147483648, + 'pdoPgsqlExpectedResult' => 2147483648, + 'pgsqlExpectedResult' => 2147483648, + 'mssqlExpectedResult' => '2147483648', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'ABS(t.col_float)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT ABS(t.col_float) FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0.125, + 'sqliteExpectedResult' => 0.125, + 'pdoPgsqlExpectedResult' => 0.125, + 'pgsqlExpectedResult' => 0.125, + 'mssqlExpectedResult' => 0.125, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 'ABS(t.col_decimal)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT ABS(t.col_decimal) FROM %s t', + 'mysqlExpectedType' => self::numericString(false, true), + 'sqliteExpectedType' => self::floatOrInt(), + 'pdoPgsqlExpectedType' => self::numericString(false, true), + 'pgsqlExpectedType' => self::numericString(false, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '0.1', + 'sqliteExpectedResult' => 0.1, + 'pdoPgsqlExpectedResult' => '0.1', + 'pgsqlExpectedResult' => '0.1', + 'mssqlExpectedResult' => '.1', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'ABS(t.col_decimal) + int data' => [ + 'data' => self::dataAllIntLike(), + 'dqlTemplate' => 'SELECT ABS(t.col_decimal) FROM %s t', + 'mysqlExpectedType' => self::numericString(false, true), + 'sqliteExpectedType' => self::floatOrInt(), + 'pdoPgsqlExpectedType' => self::numericString(false, true), + 'pgsqlExpectedType' => self::numericString(false, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.0', + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => '1.0', + 'pgsqlExpectedResult' => '1.0', + 'mssqlExpectedResult' => '1.0', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'ABS(t.col_int)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT ABS(t.col_int) FROM %s t', + 'mysqlExpectedType' => self::intNonNegative(), + 'sqliteExpectedType' => self::intNonNegative(), + 'pdoPgsqlExpectedType' => self::intNonNegative(), + 'pgsqlExpectedType' => self::intNonNegative(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 9, + 'sqliteExpectedResult' => 9, + 'pdoPgsqlExpectedResult' => 9, + 'pgsqlExpectedResult' => 9, + 'mssqlExpectedResult' => 9, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield '-ABS(t.col_int)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT -ABS(t.col_int) FROM %s t', + 'mysqlExpectedType' => IntegerRangeType::fromInterval(null, 0), + 'sqliteExpectedType' => IntegerRangeType::fromInterval(null, 0), + 'pdoPgsqlExpectedType' => IntegerRangeType::fromInterval(null, 0), + 'pgsqlExpectedType' => IntegerRangeType::fromInterval(null, 0), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => -9, + 'sqliteExpectedResult' => -9, + 'pdoPgsqlExpectedResult' => -9, + 'pgsqlExpectedResult' => -9, + 'mssqlExpectedResult' => -9, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'ABS(t.col_int_nullable)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT ABS(t.col_int_nullable) FROM %s t', + 'mysqlExpectedType' => self::intNonNegativeOrNull(), + 'sqliteExpectedType' => self::intNonNegativeOrNull(), + 'pdoPgsqlExpectedType' => self::intNonNegativeOrNull(), + 'pgsqlExpectedType' => self::intNonNegativeOrNull(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'ABS(t.col_string)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT ABS(t.col_string) FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // Operand data type is invalid + 'mysqlExpectedResult' => 0.0, + 'sqliteExpectedResult' => 0.0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'ABS(t.col_string) + int data' => [ + 'data' => self::dataAllIntLike(), + 'dqlTemplate' => 'SELECT ABS(t.col_string) FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'ABS(t.col_bool)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT ABS(t.col_bool) FROM %s t', + 'mysqlExpectedType' => self::intNonNegative(), + 'sqliteExpectedType' => self::intNonNegative(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'ABS(-1)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT ABS(-1) FROM %s t', + 'mysqlExpectedType' => self::intNonNegative(), + 'sqliteExpectedType' => self::intNonNegative(), + 'pdoPgsqlExpectedType' => self::intNonNegative(), + 'pgsqlExpectedType' => self::intNonNegative(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'ABS(1)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT ABS(1) FROM %s t', + 'mysqlExpectedType' => self::intNonNegative(), + 'sqliteExpectedType' => self::intNonNegative(), + 'pdoPgsqlExpectedType' => self::intNonNegative(), + 'pgsqlExpectedType' => self::intNonNegative(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'ABS(1.0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT ABS(1.0) FROM %s t', + 'mysqlExpectedType' => self::numericString(true, true), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::numericString(true, true), + 'pgsqlExpectedType' => self::numericString(true, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.0', + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1.0', + 'pgsqlExpectedResult' => '1.0', + 'mssqlExpectedResult' => '1.0', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'ABS(1e0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT ABS(1e0) FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::numericString(true, true), + 'pgsqlExpectedType' => self::numericString(true, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1', + 'pgsqlExpectedResult' => '1', + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "ABS('1.0')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT ABS('1.0') FROM %s t", + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => 1.0, + 'pgsqlExpectedResult' => 1.0, + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield "ABS('1')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT ABS('1') FROM %s t", + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => 1.0, + 'pgsqlExpectedResult' => 1.0, + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 'ABS(t.col_bigint)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT ABS(t.col_bigint) FROM %s t', + 'mysqlExpectedType' => self::intNonNegative(), + 'sqliteExpectedType' => self::intNonNegative(), + 'pdoPgsqlExpectedType' => self::intNonNegative(), + 'pgsqlExpectedType' => self::intNonNegative(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 2147483648, + 'sqliteExpectedResult' => 2147483648, + 'pdoPgsqlExpectedResult' => 2147483648, + 'pgsqlExpectedResult' => 2147483648, + 'mssqlExpectedResult' => '2147483648', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'ABS(t.col_mixed)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT ABS(t.col_mixed) FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => self::mixed(), + 'pgsqlExpectedType' => self::mixed(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MOD(t.col_int, 0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MOD(t.col_int, 0) FROM %s t', + 'mysqlExpectedType' => self::intNonNegativeOrNull(), + 'sqliteExpectedType' => self::intNonNegativeOrNull(), + 'pdoPgsqlExpectedType' => null, + 'pgsqlExpectedType' => null, + 'mssqlExpectedType' => null, // Divide by zero error encountered. + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MOD(t.col_int, 1)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MOD(t.col_int, 1) FROM %s t', + 'mysqlExpectedType' => self::intNonNegative(), + 'sqliteExpectedType' => self::intNonNegative(), + 'pdoPgsqlExpectedType' => self::intNonNegative(), + 'pgsqlExpectedType' => self::intNonNegative(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0, + 'sqliteExpectedResult' => 0, + 'pdoPgsqlExpectedResult' => 0, + 'pgsqlExpectedResult' => 0, + 'mssqlExpectedResult' => 0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MOD(t.col_mixed, 1)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MOD(t.col_mixed, 1) FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => self::mixed(), + 'pgsqlExpectedType' => self::mixed(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0, + 'sqliteExpectedResult' => 0, + 'pdoPgsqlExpectedResult' => 0, + 'pgsqlExpectedResult' => 0, + 'mssqlExpectedResult' => 0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "MOD(t.col_int, '1')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT MOD(t.col_int, '1') FROM %s t", + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => self::mixed(), + 'pgsqlExpectedType' => self::mixed(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0.0, + 'sqliteExpectedResult' => 0, + 'pdoPgsqlExpectedResult' => 0, + 'pgsqlExpectedResult' => 0, + 'mssqlExpectedResult' => 0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "MOD(t.col_int, '1.0')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT MOD(t.col_int, '1') FROM %s t", + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => self::mixed(), + 'pgsqlExpectedType' => self::mixed(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0.0, + 'sqliteExpectedResult' => 0, + 'pdoPgsqlExpectedResult' => 0, + 'pgsqlExpectedResult' => 0, + 'mssqlExpectedResult' => 0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MOD(t.col_int, t.col_float)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MOD(t.col_int, t.col_float) FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // The data types are incompatible in the modulo operator. + 'mysqlExpectedResult' => 0.0, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MOD(t.col_int, t.col_decimal)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MOD(t.col_int, t.col_decimal) FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => self::mixed(), + 'pgsqlExpectedType' => self::mixed(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '0.0', + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => '0.0', + 'pgsqlExpectedResult' => '0.0', + 'mssqlExpectedResult' => '.0', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MOD(t.col_float, t.col_int)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MOD(t.col_float, t.col_int) FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // The data types are incompatible in the modulo operator. + 'mysqlExpectedResult' => 0.125, + 'sqliteExpectedResult' => 0.0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MOD(t.col_decimal, t.col_int)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MOD(t.col_decimal, t.col_int) FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => self::mixed(), + 'pgsqlExpectedType' => self::mixed(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '0.1', + 'sqliteExpectedResult' => 0.0, + 'pdoPgsqlExpectedResult' => '0.1', + 'pgsqlExpectedResult' => '0.1', + 'mssqlExpectedResult' => '.1', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MOD(t.col_string, t.col_string)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MOD(t.col_string, t.col_string) FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => null, // Undefined function + 'pgsqlExpectedType' => null, // Undefined function + 'mssqlExpectedType' => null, // The data types are incompatible in the modulo operator. + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MOD(t.col_int, t.col_int)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MOD(t.col_int, t.col_int) FROM %s t', + 'mysqlExpectedType' => self::intNonNegativeOrNull(), + 'sqliteExpectedType' => self::intNonNegativeOrNull(), + 'pdoPgsqlExpectedType' => self::intNonNegative(), + 'pgsqlExpectedType' => self::intNonNegative(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0, + 'sqliteExpectedResult' => 0, + 'pdoPgsqlExpectedResult' => 0, + 'pgsqlExpectedResult' => 0, + 'mssqlExpectedResult' => 0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MOD(t.col_int, t.col_int_nullable)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MOD(t.col_int, t.col_int_nullable) FROM %s t', + 'mysqlExpectedType' => self::intNonNegativeOrNull(), + 'sqliteExpectedType' => self::intNonNegativeOrNull(), + 'pdoPgsqlExpectedType' => self::intNonNegativeOrNull(), + 'pgsqlExpectedType' => self::intNonNegativeOrNull(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MOD(10, 7)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MOD(10, 7) FROM %s t', + 'mysqlExpectedType' => self::intNonNegative(), + 'sqliteExpectedType' => self::intNonNegative(), + 'pdoPgsqlExpectedType' => self::intNonNegative(), + 'pgsqlExpectedType' => self::intNonNegative(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 3, + 'sqliteExpectedResult' => 3, + 'pdoPgsqlExpectedResult' => 3, + 'pgsqlExpectedResult' => 3, + 'mssqlExpectedResult' => 3, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MOD(10, -7)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MOD(10, -7) FROM %s t', + 'mysqlExpectedType' => self::intNonNegative(), + 'sqliteExpectedType' => self::intNonNegative(), + 'pdoPgsqlExpectedType' => self::intNonNegative(), + 'pgsqlExpectedType' => self::intNonNegative(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 3, + 'sqliteExpectedResult' => 3, + 'pdoPgsqlExpectedResult' => 3, + 'pgsqlExpectedResult' => 3, + 'mssqlExpectedResult' => 3, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'MOD(t.col_bigint, t.col_bigint)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT MOD(t.col_bigint, t.col_bigint) FROM %s t', + 'mysqlExpectedType' => self::intNonNegativeOrNull(), + 'sqliteExpectedType' => self::intNonNegativeOrNull(), + 'pdoPgsqlExpectedType' => self::intNonNegative(), + 'pgsqlExpectedType' => self::intNonNegative(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0, + 'sqliteExpectedResult' => 0, + 'pdoPgsqlExpectedResult' => 0, + 'pgsqlExpectedResult' => 0, + 'mssqlExpectedResult' => '0', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'BIT_AND(t.col_bigint, t.col_bigint)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT BIT_AND(t.col_bigint, t.col_bigint) FROM %s t', + 'mysqlExpectedType' => self::intNonNegative(), + 'sqliteExpectedType' => self::intNonNegative(), + 'pdoPgsqlExpectedType' => self::intNonNegative(), + 'pgsqlExpectedType' => self::intNonNegative(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 2147483648, + 'sqliteExpectedResult' => 2147483648, + 'pdoPgsqlExpectedResult' => 2147483648, + 'pgsqlExpectedResult' => 2147483648, + 'mssqlExpectedResult' => '2147483648', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'BIT_AND(t.col_int, t.col_int)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT BIT_AND(t.col_int, t.col_int) FROM %s t', + 'mysqlExpectedType' => self::intNonNegative(), + 'sqliteExpectedType' => self::intNonNegative(), + 'pdoPgsqlExpectedType' => self::intNonNegative(), + 'pgsqlExpectedType' => self::intNonNegative(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 9, + 'sqliteExpectedResult' => 9, + 'pdoPgsqlExpectedResult' => 9, + 'pgsqlExpectedResult' => 9, + 'mssqlExpectedResult' => 9, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'BIT_AND(t.col_mixed, t.col_mixed)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT BIT_AND(t.col_mixed, t.col_mixed) FROM %s t', + 'mysqlExpectedType' => self::intNonNegativeOrNull(), + 'sqliteExpectedType' => self::intNonNegativeOrNull(), + 'pdoPgsqlExpectedType' => self::intNonNegativeOrNull(), + 'pgsqlExpectedType' => self::intNonNegativeOrNull(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'BIT_AND(t.col_int, t.col_int_nullable)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT BIT_AND(t.col_int, t.col_int_nullable) FROM %s t', + 'mysqlExpectedType' => self::intNonNegativeOrNull(), + 'sqliteExpectedType' => self::intNonNegativeOrNull(), + 'pdoPgsqlExpectedType' => self::intNonNegativeOrNull(), + 'pgsqlExpectedType' => self::intNonNegativeOrNull(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'BIT_AND(1, 0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT BIT_AND(1, 0) FROM %s t', + 'mysqlExpectedType' => self::intNonNegative(), + 'sqliteExpectedType' => self::intNonNegative(), + 'pdoPgsqlExpectedType' => self::intNonNegative(), + 'pgsqlExpectedType' => self::intNonNegative(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0, + 'sqliteExpectedResult' => 0, + 'pdoPgsqlExpectedResult' => 0, + 'pgsqlExpectedResult' => 0, + 'mssqlExpectedResult' => 0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'BIT_AND(t.col_string, t.col_string)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT BIT_AND(t.col_string, t.col_string) FROM %s t', + 'mysqlExpectedType' => self::intNonNegative(), + 'sqliteExpectedType' => self::intNonNegative(), + 'pdoPgsqlExpectedType' => null, + 'pgsqlExpectedType' => null, + 'mssqlExpectedType' => null, + 'mysqlExpectedResult' => 0, + 'sqliteExpectedResult' => 0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'DATE_DIFF(CURRENT_DATE(), CURRENT_DATE())' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT DATE_DIFF('2024-01-01 12:00', '2024-01-01 11:00') FROM %s t", + 'mysqlExpectedType' => self::int(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0, + 'sqliteExpectedResult' => 0.0, + 'pdoPgsqlExpectedResult' => 0, + 'pgsqlExpectedResult' => 0, + 'mssqlExpectedResult' => 0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'DATE_DIFF(CURRENT_DATE(), t.col_string_nullable)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT DATE_DIFF('2024-01-01 12:00', t.col_string_nullable) FROM %s t", + 'mysqlExpectedType' => self::intOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::intOrNull(), + 'pgsqlExpectedType' => self::intOrNull(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'DATE_DIFF(CURRENT_DATE(), t.col_mixed)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT DATE_DIFF('2024-01-01 12:00', t.col_mixed) FROM %s t", + 'mysqlExpectedType' => self::intOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => null, + 'pgsqlExpectedType' => null, + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => 2460310.0, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => 45289, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'SQRT(t.col_float)' => [ + 'data' => self::dataSqrt(), + 'dqlTemplate' => 'SELECT SQRT(t.col_float) FROM %s t', + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => 1.0, + 'pgsqlExpectedResult' => 1.0, + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 'SQRT(t.col_decimal)' => [ + 'data' => self::dataSqrt(), + 'dqlTemplate' => 'SELECT SQRT(t.col_decimal) FROM %s t', + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::numericString(false, true), + 'pgsqlExpectedType' => self::numericString(false, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1.000000000000000', + 'pgsqlExpectedResult' => '1.000000000000000', + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'SQRT(t.col_int)' => [ + 'data' => self::dataSqrt(), + 'dqlTemplate' => 'SELECT SQRT(t.col_int) FROM %s t', + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 3.0, + 'sqliteExpectedResult' => 3.0, + 'pdoPgsqlExpectedResult' => 3.0, + 'pgsqlExpectedResult' => 3.0, + 'mssqlExpectedResult' => 3.0, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 'SQRT(t.col_mixed)' => [ + 'data' => self::dataSqrt(), + 'dqlTemplate' => 'SELECT SQRT(t.col_mixed) FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => self::mixed(), + 'pgsqlExpectedType' => self::mixed(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => 1.0, + 'pgsqlExpectedResult' => 1.0, + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 'SQRT(t.col_int_nullable)' => [ + 'data' => self::dataSqrt(), + 'dqlTemplate' => 'SELECT SQRT(t.col_int_nullable) FROM %s t', + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => PHP_VERSION_ID >= 80100 && !self::hasDbal4() ? null : self::floatOrNull(), // fails in UDF since PHP 8.1: sqrt(): Passing null to parameter #1 ($num) of type float is deprecated + 'pdoPgsqlExpectedType' => self::floatOrNull(), + 'pgsqlExpectedType' => self::floatOrNull(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => self::hasDbal4() ? null : 0.0, // 0.0 caused by UDF wired through PHP's sqrt() which returns 0.0 for null + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 'SQRT(-1)' => [ + 'data' => self::dataSqrt(), + 'dqlTemplate' => 'SELECT SQRT(-1) FROM %s t', + 'mysqlExpectedType' => self::floatOrNull(), + 'sqliteExpectedType' => self::floatOrNull(), + 'pdoPgsqlExpectedType' => null, // failure: cannot take square root of a negative number + 'pgsqlExpectedType' => null, // failure: cannot take square root of a negative number + 'mssqlExpectedType' => null, // An invalid floating point operation occurred. + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'SQRT(1)' => [ + 'data' => self::dataSqrt(), + 'dqlTemplate' => 'SELECT SQRT(1) FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => 1.0, + 'pgsqlExpectedResult' => 1.0, + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield "SQRT('1')" => [ + 'data' => self::dataSqrt(), + 'dqlTemplate' => "SELECT SQRT('1') FROM %s t", + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => 1.0, + 'pgsqlExpectedResult' => 1.0, + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield "SQRT('1.0')" => [ + 'data' => self::dataSqrt(), + 'dqlTemplate' => "SELECT SQRT('1.0') FROM %s t", + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => 1.0, + 'pgsqlExpectedResult' => 1.0, + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield "SQRT('1e0')" => [ + 'data' => self::dataSqrt(), + 'dqlTemplate' => "SELECT SQRT('1e0') FROM %s t", + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::float(), + 'pgsqlExpectedType' => self::float(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => 1.0, + 'pgsqlExpectedResult' => 1.0, + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield "SQRT('foo')" => [ + 'data' => self::dataSqrt(), + 'dqlTemplate' => "SELECT SQRT('foo') FROM %s t", + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::hasDbal4() ? self::mixed() : null, // fails in UDF: sqrt(): Argument #1 ($num) must be of type float, string given + 'pdoPgsqlExpectedType' => null, // Invalid text representation + 'pgsqlExpectedType' => null, // Invalid text representation + 'mssqlExpectedType' => null, // Error converting data type + 'mysqlExpectedResult' => 0.0, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'SQRT(t.col_string)' => [ + 'data' => self::dataSqrt(), + 'dqlTemplate' => 'SELECT SQRT(t.col_string) FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::hasDbal4() ? self::mixed() : null, // fails in UDF: sqrt(): Argument #1 ($num) must be of type float, string given + 'pdoPgsqlExpectedType' => null, // undefined function + 'pgsqlExpectedType' => null, // undefined function + 'mssqlExpectedType' => null, // Error converting data type + 'mysqlExpectedResult' => 0.0, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'SQRT(1.0)' => [ + 'data' => self::dataSqrt(), + 'dqlTemplate' => 'SELECT SQRT(1.0) FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::numericString(true, true), + 'pgsqlExpectedType' => self::numericString(true, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1.000000000000000', + 'pgsqlExpectedResult' => '1.000000000000000', + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COUNT(t)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COUNT(t) FROM %s t', + 'mysqlExpectedType' => self::intNonNegative(), + 'sqliteExpectedType' => self::intNonNegative(), + 'pdoPgsqlExpectedType' => self::intNonNegative(), + 'pgsqlExpectedType' => self::intNonNegative(), + 'mssqlExpectedType' => self::intNonNegative(), + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_NONE, + ]; + + yield 'SUBSELECT' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t1.col_int, (SELECT COUNT(t2.col_int) FROM ' . PlatformEntity::class . ' t2) FROM %s t1', + 'mysqlExpectedType' => self::int(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::int(), + 'mysqlExpectedResult' => 9, + 'sqliteExpectedResult' => 9, + 'pdoPgsqlExpectedResult' => 9, + 'pgsqlExpectedResult' => 9, + 'mssqlExpectedResult' => 9, + 'stringify' => self::STRINGIFY_NONE, + ]; + + yield 'COUNT(t.col_int)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COUNT(t.col_int) FROM %s t', + 'mysqlExpectedType' => self::intNonNegative(), + 'sqliteExpectedType' => self::intNonNegative(), + 'pdoPgsqlExpectedType' => self::intNonNegative(), + 'pgsqlExpectedType' => self::intNonNegative(), + 'mssqlExpectedType' => self::intNonNegative(), + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_NONE, + ]; + + yield 'COUNT(t.col_mixed)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COUNT(t.col_mixed) FROM %s t', + 'mysqlExpectedType' => self::intNonNegative(), + 'sqliteExpectedType' => self::intNonNegative(), + 'pdoPgsqlExpectedType' => self::intNonNegative(), + 'pgsqlExpectedType' => self::intNonNegative(), + 'mssqlExpectedType' => self::intNonNegative(), + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_NONE, + ]; + + yield 'COUNT(1)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COUNT(1) FROM %s t', + 'mysqlExpectedType' => self::intNonNegative(), + 'sqliteExpectedType' => self::intNonNegative(), + 'pdoPgsqlExpectedType' => self::intNonNegative(), + 'pgsqlExpectedType' => self::intNonNegative(), + 'mssqlExpectedType' => self::intNonNegative(), + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_NONE, + ]; + + yield 't.col_mixed' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT t.col_mixed FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => self::mixed(), + 'pgsqlExpectedType' => self::mixed(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_NONE, + ]; + + yield 'INT_PI()' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT INT_PI() FROM %s t', + 'mysqlExpectedType' => self::int(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::int(), + 'mysqlExpectedResult' => 3, + 'sqliteExpectedResult' => 3, + 'pdoPgsqlExpectedResult' => 3, + 'pgsqlExpectedResult' => 3, + 'mssqlExpectedResult' => 3, + 'stringify' => self::STRINGIFY_NONE, + ]; + + yield '-INT_PI()' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT -INT_PI() FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => self::mixed(), + 'pgsqlExpectedType' => self::mixed(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '-3.14159', + 'sqliteExpectedResult' => -3.14159, + 'pdoPgsqlExpectedResult' => '-3.14159', + 'pgsqlExpectedResult' => '-3.14159', + 'mssqlExpectedResult' => '-3.14159', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'BOOL_PI()' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT BOOL_PI() FROM %s t', + 'mysqlExpectedType' => self::bool(), + 'sqliteExpectedType' => self::bool(), + 'pdoPgsqlExpectedType' => self::bool(), + 'pgsqlExpectedType' => self::bool(), + 'mssqlExpectedType' => self::bool(), + 'mysqlExpectedResult' => true, + 'sqliteExpectedResult' => true, + 'pdoPgsqlExpectedResult' => true, + 'pgsqlExpectedResult' => true, + 'mssqlExpectedResult' => true, + 'stringify' => self::STRINGIFY_NONE, + ]; + + yield 'STRING_PI()' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT STRING_PI() FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => self::mixed(), + 'pgsqlExpectedType' => self::mixed(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '3.14159', + 'sqliteExpectedResult' => 3.14159, + 'pdoPgsqlExpectedResult' => '3.14159', + 'pgsqlExpectedResult' => '3.14159', + 'mssqlExpectedResult' => '3.14159', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'INT_WRAP(MIN(t.col_float)) + no data' => [ + 'data' => self::dataNone(), + 'dqlTemplate' => 'SELECT INT_WRAP(MIN(t.col_float)) FROM %s t', + 'mysqlExpectedType' => self::intOrNull(), + 'sqliteExpectedType' => self::intOrNull(), + 'pdoPgsqlExpectedType' => self::intOrNull(), + 'pgsqlExpectedType' => self::intOrNull(), + 'mssqlExpectedType' => self::intOrNull(), + 'mysqlExpectedResult' => null, + 'sqliteExpectedResult' => null, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_NONE, + ]; + + yield 'INT_WRAP(MIN(t.col_float))' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT INT_WRAP(MIN(t.col_float)) FROM %s t', + 'mysqlExpectedType' => self::intOrNull(), + 'sqliteExpectedType' => self::intOrNull(), + 'pdoPgsqlExpectedType' => self::intOrNull(), + 'pgsqlExpectedType' => self::intOrNull(), + 'mssqlExpectedType' => self::intOrNull(), + 'mysqlExpectedResult' => 0, + 'sqliteExpectedResult' => 0, + 'pdoPgsqlExpectedResult' => 0, + 'pgsqlExpectedResult' => 0, + 'mssqlExpectedResult' => 0, + 'stringify' => self::STRINGIFY_NONE, + ]; + + yield 'COALESCE(t.col_datetime, t.col_datetime)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(t.col_datetime, t.col_datetime) FROM %s t', + 'mysqlExpectedType' => self::string(), + 'sqliteExpectedType' => self::string(), + 'pdoPgsqlExpectedType' => self::string(), + 'pgsqlExpectedType' => self::string(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '2024-01-31 12:59:59', + 'sqliteExpectedResult' => '2024-01-31 12:59:59', + 'pdoPgsqlExpectedResult' => '2024-01-31 12:59:59', + 'pgsqlExpectedResult' => '2024-01-31 12:59:59', + 'mssqlExpectedResult' => '2024-01-31 12:59:59.000000', // doctrine/dbal changes default ReturnDatesAsStrings to true + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(SUM(t.col_int_nullable), 0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(SUM(t.col_int_nullable), 0) FROM %s t', + 'mysqlExpectedType' => self::numericString(true, true), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => TypeCombinator::union(self::numericString(true, true), self::int()), + 'pgsqlExpectedType' => TypeCombinator::union(self::numericString(true, true), self::int()), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '0', + 'sqliteExpectedResult' => 0, + 'pdoPgsqlExpectedResult' => 0, + 'pgsqlExpectedResult' => 0, + 'mssqlExpectedResult' => 0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(SUM(ABS(t.col_int)), 0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(SUM(ABS(t.col_int)), 0) FROM %s t', + 'mysqlExpectedType' => self::numericString(true, true), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => TypeCombinator::union(self::int(), self::numericString(true, true)), + 'pgsqlExpectedType' => TypeCombinator::union(self::int(), self::numericString(true, true)), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '9', + 'sqliteExpectedResult' => 9, + 'pdoPgsqlExpectedResult' => 9, + 'pgsqlExpectedResult' => 9, + 'mssqlExpectedResult' => 9, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "COALESCE(t.col_int_nullable, 'foo')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT COALESCE(t.col_int_nullable, 'foo') FROM %s t", + 'mysqlExpectedType' => self::string(), + 'sqliteExpectedType' => TypeCombinator::union(self::int(), self::string()), + 'pdoPgsqlExpectedType' => null, // COALESCE types cannot be matched + 'pgsqlExpectedType' => null, // COALESCE types cannot be matched + 'mssqlExpectedType' => null, // Conversion failed + 'mysqlExpectedResult' => 'foo', + 'sqliteExpectedResult' => 'foo', + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "COALESCE(t.col_int, 'foo')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT COALESCE(t.col_int, 'foo') FROM %s t", + 'mysqlExpectedType' => self::string(), + 'sqliteExpectedType' => TypeCombinator::union(self::int(), self::string()), + 'pdoPgsqlExpectedType' => null, // COALESCE types cannot be matched + 'pgsqlExpectedType' => null, // COALESCE types cannot be matched + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '9', + 'sqliteExpectedResult' => 9, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => 9, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "COALESCE(t.col_bool, 'foo')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT COALESCE(t.col_bool, 'foo') FROM %s t", + 'mysqlExpectedType' => self::string(), + 'sqliteExpectedType' => TypeCombinator::union(self::int(), self::string()), + 'pdoPgsqlExpectedType' => null, // COALESCE types cannot be matched + 'pgsqlExpectedType' => null, // COALESCE types cannot be matched + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1', + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "COALESCE(1, 'foo')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT COALESCE(1, 'foo') FROM %s t", + 'mysqlExpectedType' => self::string(), + 'sqliteExpectedType' => TypeCombinator::union(self::int(), self::string()), + 'pdoPgsqlExpectedType' => null, // COALESCE types cannot be matched + 'pgsqlExpectedType' => null, // COALESCE types cannot be matched + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1', + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "COALESCE(1, '1')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT COALESCE(1, '1') FROM %s t", + 'mysqlExpectedType' => self::numericString(true, true), + 'sqliteExpectedType' => TypeCombinator::union(self::int(), self::numericString(true, true)), + 'pdoPgsqlExpectedType' => TypeCombinator::union(self::int(), self::numericString(true, true)), + 'pgsqlExpectedType' => TypeCombinator::union(self::int(), self::numericString(true, true)), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1', + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(1, 1.0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(1, 1.0) FROM %s t', + 'mysqlExpectedType' => self::numericString(), + 'sqliteExpectedType' => TypeCombinator::union(self::int(), self::float()), + 'pdoPgsqlExpectedType' => TypeCombinator::union(self::int(), self::numericString(true, true)), + 'pgsqlExpectedType' => TypeCombinator::union(self::int(), self::numericString(true, true)), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.0', + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => '1', + 'pgsqlExpectedResult' => '1', + 'mssqlExpectedResult' => '1.0', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(0, 0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(0, 0) FROM %s t', + 'mysqlExpectedType' => self::int(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0, + 'sqliteExpectedResult' => 0, + 'pdoPgsqlExpectedResult' => 0, + 'pgsqlExpectedResult' => 0, + 'mssqlExpectedResult' => 0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(1.0, 1.0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(1.0, 1.0) FROM %s t', + 'mysqlExpectedType' => self::numericString(true, true), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::numericString(true, true), + 'pgsqlExpectedType' => self::numericString(true, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1.0', + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1.0', + 'pgsqlExpectedResult' => '1.0', + 'mssqlExpectedResult' => '1.0', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(1e0, 1.0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(1e0, 1.0) FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => self::numericString(true, true), + 'pgsqlExpectedType' => self::numericString(true, true), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1.0, + 'pdoPgsqlExpectedResult' => '1', + 'pgsqlExpectedResult' => '1', + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(1, 1.0, 1e0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(1, 1.0, 1e0) FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => TypeCombinator::union(self::float(), self::int()), + 'pdoPgsqlExpectedType' => TypeCombinator::union(self::int(), self::numericString(true, true)), + 'pgsqlExpectedType' => TypeCombinator::union(self::int(), self::numericString(true, true)), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => '1', + 'pgsqlExpectedResult' => '1', + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "COALESCE(1, 1.0, 1e0, '1')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => "SELECT COALESCE(1, 1.0, 1e0, '1') FROM %s t", + 'mysqlExpectedType' => self::numericString(), + 'sqliteExpectedType' => TypeCombinator::union(self::float(), self::int(), self::numericString(true, true)), + 'pdoPgsqlExpectedType' => TypeCombinator::union(self::int(), self::numericString(true, true)), + 'pgsqlExpectedType' => TypeCombinator::union(self::int(), self::numericString(true, true)), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '1', + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => '1', + 'pgsqlExpectedResult' => '1', + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(t.col_int_nullable, 0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(t.col_int_nullable, 0) FROM %s t', + 'mysqlExpectedType' => self::int(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0, + 'sqliteExpectedResult' => 0, + 'pdoPgsqlExpectedResult' => 0, + 'pgsqlExpectedResult' => 0, + 'mssqlExpectedResult' => 0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(t.col_int_nullable, t.col_bool)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(t.col_int_nullable, t.col_bool) FROM %s t', + 'mysqlExpectedType' => self::int(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => null, // COALESCE types cannot be matched + 'pgsqlExpectedType' => null, // COALESCE types cannot be matched + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(t.col_float_nullable, 0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(t.col_float_nullable, 0) FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => TypeCombinator::union(self::float(), self::int()), + 'pdoPgsqlExpectedType' => PHP_VERSION_ID < 80400 + ? TypeCombinator::union(self::numericString(), self::int()) + : TypeCombinator::union(self::float(), self::int()), + 'pgsqlExpectedType' => TypeCombinator::union(self::float(), self::int()), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0.0, + 'sqliteExpectedResult' => 0, + 'pdoPgsqlExpectedResult' => PHP_VERSION_ID < 80400 ? '0' : 0.0, + 'pgsqlExpectedResult' => 0.0, + 'mssqlExpectedResult' => 0.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(t.col_float_nullable, 0.0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(t.col_float_nullable, 0.0) FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => self::float(), + 'pdoPgsqlExpectedType' => TypeCombinator::union(self::float(), self::numericString(false, true)), + 'pgsqlExpectedType' => TypeCombinator::union(self::float(), self::numericString(false, true)), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0.0, + 'sqliteExpectedResult' => 0.0, + 'pdoPgsqlExpectedResult' => 0.0, + 'pgsqlExpectedResult' => 0.0, + 'mssqlExpectedResult' => 0.0, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 'COALESCE(t.col_int_nullable, t.col_decimal_nullable, 0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(t.col_int_nullable, t.col_decimal_nullable, 0) FROM %s t', + 'mysqlExpectedType' => self::numericString(), + 'sqliteExpectedType' => TypeCombinator::union(self::float(), self::int()), + 'pdoPgsqlExpectedType' => TypeCombinator::union(self::numericString(), self::int()), + 'pgsqlExpectedType' => TypeCombinator::union(self::numericString(), self::int()), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '0.0', + 'sqliteExpectedResult' => 0, + 'pdoPgsqlExpectedResult' => '0', + 'pgsqlExpectedResult' => '0', + 'mssqlExpectedResult' => '.0', + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, 0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, 0) FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => TypeCombinator::union(self::float(), self::int()), + 'pdoPgsqlExpectedType' => PHP_VERSION_ID < 80400 + ? TypeCombinator::union(self::numericString(), self::int()) + : TypeCombinator::union(self::numericString(), self::int(), self::float()), + 'pgsqlExpectedType' => TypeCombinator::union(self::numericString(), self::int(), self::float()), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0.0, + 'sqliteExpectedResult' => 0, + 'pdoPgsqlExpectedResult' => PHP_VERSION_ID < 80400 ? '0' : 0.0, + 'pgsqlExpectedResult' => 0.0, + 'mssqlExpectedResult' => 0.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, 0.0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, 0.0) FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => TypeCombinator::union(self::float(), self::int()), + 'pdoPgsqlExpectedType' => PHP_VERSION_ID < 80400 + ? TypeCombinator::union(self::numericString(), self::int()) + : TypeCombinator::union(self::numericString(), self::int(), self::float()), + 'pgsqlExpectedType' => TypeCombinator::union(self::numericString(), self::int(), self::float()), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0.0, + 'sqliteExpectedResult' => 0.0, + 'pdoPgsqlExpectedResult' => PHP_VERSION_ID < 80400 ? '0' : 0.0, + 'pgsqlExpectedResult' => 0.0, + 'mssqlExpectedResult' => 0.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, 0e0)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, 0e0) FROM %s t', + 'mysqlExpectedType' => self::float(), + 'sqliteExpectedType' => TypeCombinator::union(self::float(), self::int()), + 'pdoPgsqlExpectedType' => PHP_VERSION_ID < 80400 + ? TypeCombinator::union(self::numericString(), self::int()) + : TypeCombinator::union(self::numericString(), self::int(), self::float()), + 'pgsqlExpectedType' => TypeCombinator::union(self::numericString(), self::int(), self::float()), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 0.0, + 'sqliteExpectedResult' => 0.0, + 'pdoPgsqlExpectedResult' => PHP_VERSION_ID < 80400 ? '0' : 0.0, + 'pgsqlExpectedResult' => 0.0, + 'mssqlExpectedResult' => 0.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield "COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, '0')" => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, \'0\') FROM %s t', + 'mysqlExpectedType' => self::numericString(), + 'sqliteExpectedType' => TypeCombinator::union(self::float(), self::int(), self::numericString()), + 'pdoPgsqlExpectedType' => PHP_VERSION_ID < 80400 + ? TypeCombinator::union(self::numericString(), self::int()) + : TypeCombinator::union(self::numericString(), self::int(), self::float()), + 'pgsqlExpectedType' => TypeCombinator::union(self::numericString(), self::int(), self::float()), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '0', + 'sqliteExpectedResult' => '0', + 'pdoPgsqlExpectedResult' => PHP_VERSION_ID < 80400 ? '0' : 0.0, + 'pgsqlExpectedResult' => 0.0, + 'mssqlExpectedResult' => 0.0, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, t.col_string)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, t.col_string) FROM %s t', + 'mysqlExpectedType' => self::string(), + 'sqliteExpectedType' => TypeCombinator::union(self::float(), self::int(), self::string()), + 'pdoPgsqlExpectedType' => null, // COALESCE types cannot be matched + 'pgsqlExpectedType' => null, // COALESCE types cannot be matched + 'mssqlExpectedType' => null, // Error converting data + 'mysqlExpectedResult' => 'foobar', + 'sqliteExpectedResult' => 'foobar', + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => null, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, t.col_mixed)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(t.col_int_nullable, t.col_decimal_nullable, t.col_float_nullable, t.col_mixed) FROM %s t', + 'mysqlExpectedType' => self::mixed(), + 'sqliteExpectedType' => self::mixed(), + 'pdoPgsqlExpectedType' => self::mixed(), + 'pgsqlExpectedType' => self::mixed(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1.0, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1.0, + 'pgsqlExpectedResult' => 1.0, + 'mssqlExpectedResult' => 1.0, + 'stringify' => self::STRINGIFY_PG_FLOAT, + ]; + + yield 'COALESCE(t.col_string_nullable, t.col_int)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT COALESCE(t.col_string_nullable, t.col_int) FROM %s t', + 'mysqlExpectedType' => self::string(), + 'sqliteExpectedType' => TypeCombinator::union(self::int(), self::string()), + 'pdoPgsqlExpectedType' => null, // COALESCE types cannot be matched + 'pgsqlExpectedType' => null, // COALESCE types cannot be matched + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => '9', + 'sqliteExpectedResult' => 9, + 'pdoPgsqlExpectedResult' => null, + 'pgsqlExpectedResult' => null, + 'mssqlExpectedResult' => 9, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + + yield 'IDENTITY(t.related_entity)' => [ + 'data' => self::dataDefault(), + 'dqlTemplate' => 'SELECT IDENTITY(t.related_entity) FROM %s t', + 'mysqlExpectedType' => self::int(), + 'sqliteExpectedType' => self::int(), + 'pdoPgsqlExpectedType' => self::int(), + 'pgsqlExpectedType' => self::int(), + 'mssqlExpectedType' => self::mixed(), + 'mysqlExpectedResult' => 1, + 'sqliteExpectedResult' => 1, + 'pdoPgsqlExpectedResult' => 1, + 'pgsqlExpectedResult' => 1, + 'mssqlExpectedResult' => 1, + 'stringify' => self::STRINGIFY_DEFAULT, + ]; + } + + /** + * @param mixed $expectedFirstResult + * @param array $data + * @param self::STRINGIFY_* $stringification + */ + private function performDriverTest( + string $driver, + string $configName, + array $data, + string $dqlTemplate, + string $dataset, + int $phpVersion, + ?Type $expectedInferredType, + $expectedFirstResult, + string $stringification, + bool $useUnknownDriverForInference = false + ): void + { + $connectionParams = [ + 'driver' => $driver, + 'driverOptions' => self::CONNECTION_CONFIGS[$configName], + ] + $this->getConnectionParamsForDriver($driver); + + $dql = sprintf($dqlTemplate, PlatformEntity::class); + + $connection = $this->createConnection($connectionParams); + $query = $this->getQuery($connection, $dql, $data); + $sql = $query->getSQL(); + + self::assertIsString($sql); + + try { + $result = $query->getSingleResult(); + $realResultType = ConstantTypeHelper::getTypeFromValue($result); + + if ($useUnknownDriverForInference) { + $query = $this->cloneQueryAndInjectConnectionWithUnknownPdoMysqlDriver($query); + } + + $inferredType = $this->getInferredType($query); + + } catch (Throwable $e) { + if ($expectedInferredType === null) { + return; + } + throw $e; + } finally { + $connection->close(); + } + + if ($expectedInferredType === null) { + self::fail(sprintf( + "Expected failure, but none occurred\n\nDriver: %s\nConfig: %s\nDataset: %s\nDQL: %s\nSQL: %s\nReal result: %s\nInferred type: %s\n", + $driver, + $configName, + $dataset, + $dql, + $sql, + $realResultType->describe(VerbosityLevel::precise()), + $inferredType->describe(VerbosityLevel::precise()), + )); + } + + $driverDetector = new DriverDetector(); + $driverType = $driverDetector->detect($connection); + + $stringify = $this->shouldStringify($stringification, $driverType, $phpVersion, $configName); + if ( + $stringify + && !$useUnknownDriverForInference // do not stringify, we already passed union with stringified one above + ) { + $expectedInferredType = self::stringifyType($expectedInferredType); + } + + $this->assertRealResultMatchesExpected($result, $expectedFirstResult, $driver, $configName, $dql, $sql, $dataset, $phpVersion, $stringify); + $this->assertRealResultMatchesInferred($result, $driver, $configName, $dql, $sql, $dataset, $phpVersion, $inferredType, $realResultType); + $this->assertInferredResultMatchesExpected($result, $driver, $configName, $dql, $sql, $dataset, $phpVersion, $inferredType, $expectedInferredType); + } + + /** + * @param array $connectionParams + */ + private function createConnection( + array $connectionParams + ): Connection + { + $connectionConfig = new DbalConfiguration(); + $connectionConfig->setMiddlewares([ + new Middleware($this->createMock(LoggerInterface::class)), // ensures DriverType fallback detection is tested + ]); + $connection = DriverManager::getConnection($connectionParams, $connectionConfig); + + $schemaManager = method_exists($connection, 'createSchemaManager') + ? $connection->createSchemaManager() + : $connection->getSchemaManager(); + + if (!isset($connectionParams['dbname'])) { + if (!in_array('foo', $schemaManager->listDatabases(), true)) { + $connection->executeQuery('CREATE DATABASE foo'); + } + $connection->executeQuery('USE foo'); + } + + if ($connectionParams['driver'] === 'pdo_mysql') { + $connection->executeQuery('SET GLOBAL max_connections = 1000'); + } + + return $connection; + } + + /** + * @param array $data + * @return Query $query + */ + private function getQuery( + Connection $connection, + string $dqlTemplate, + array $data + ): Query + { + if (!DbalType::hasType(MixedCustomType::NAME)) { + DbalType::addType(MixedCustomType::NAME, MixedCustomType::class); + } + + $config = $this->createOrmConfig(); + $entityManager = new EntityManager($connection, $config); + + $schemaTool = new SchemaTool($entityManager); + $classes = $entityManager->getMetadataFactory()->getAllMetadata(); + $schemaTool->dropSchema($classes); + $schemaTool->createSchema($classes); + + $relatedEntity = new PlatformRelatedEntity(); + $relatedEntity->id = 1; + $entityManager->persist($relatedEntity); + + foreach ($data as $rowData) { + $entity = new PlatformEntity(); + $entity->related_entity = $relatedEntity; + + foreach ($rowData as $column => $value) { + $entity->$column = $value; // @phpstan-ignore-line Intentionally dynamic + } + $entityManager->persist($entity); + } + + $entityManager->flush(); + + $dql = sprintf($dqlTemplate, PlatformEntity::class); + + return $entityManager->createQuery($dql); + } + + /** + * @param Query $query + */ + private function getInferredType(Query $query): Type + { + $typeBuilder = new QueryResultTypeBuilder(); + $phpVersion = new PhpVersion(PHP_VERSION_ID); // @phpstan-ignore-line ctor not in bc promise + QueryResultTypeWalker::walk( + $query, + $typeBuilder, + self::getContainer()->getByType(DescriptorRegistry::class), + $phpVersion, + new DriverDetector(), + ); + + return $typeBuilder->getResultType(); + } + + /** + * @param mixed $realResult + * @param mixed $expectedFirstResult + */ + private function assertRealResultMatchesExpected( + $realResult, + $expectedFirstResult, + string $driver, + string $configName, + string $dql, + string $sql, + string $dataset, + int $phpVersion, + bool $stringified + ): void + { + $humanReadablePhpVersion = $this->getHumanReadablePhpVersion($phpVersion); + + $firstResult = reset($realResult); + $realFirstResult = var_export($firstResult, true); + $expectedFirstResultExported = var_export($expectedFirstResult, true); + + $is = $stringified + ? new IsEqual($expectedFirstResult) // loose comparison for stringified + : new IsIdentical($expectedFirstResult); + + if ($stringified && $firstResult !== null) { + self::assertIsString( + $firstResult, + sprintf( + "Stringified result returned non-string\n\nDriver: %s\nConfig: %s\nDataset: %s\nDQL: %s\nPHP: %s\nReal first item: %s\n", + $driver, + $configName, + $dataset, + $dql, + $humanReadablePhpVersion, + $realFirstResult, + ), + ); + } + + self::assertThat( + $firstResult, + $is, + sprintf( + "Mismatch between expected result and fetched result\n\nDriver: %s\nConfig: %s\nDataset: %s\nDQL: %s\nSQL: %s\nPHP: %s\nReal first item: %s\nExpected first item: %s\n", + $driver, + $configName, + $dataset, + $dql, + $sql, + $humanReadablePhpVersion, + $realFirstResult, + $expectedFirstResultExported, + ), + ); + } + + /** + * @param mixed $realResult + */ + private function assertRealResultMatchesInferred( + $realResult, + string $driver, + string $configName, + string $dql, + string $sql, + string $dataset, + int $phpVersion, + Type $inferredType, + Type $realType + ): void + { + $firstResult = reset($realResult); + $realFirstResult = var_export($firstResult, true); + + self::assertTrue( + $inferredType->accepts($realType, true)->yes(), + sprintf( + "Inferred type does not accept fetched result!\n\nDriver: %s\nConfig: %s\nDataset: %s\nDQL: %s\nSQL: %s\nPHP: %s\nReal first result: %s\nInferred type: %s\nReal type: %s\n", + $driver, + $configName, + $dataset, + $dql, + $sql, + $this->getHumanReadablePhpVersion($phpVersion), + $realFirstResult, + $inferredType->describe(VerbosityLevel::precise()), + $realType->describe(VerbosityLevel::precise()), + ), + ); + } + + /** + * @param mixed $result + */ + private function assertInferredResultMatchesExpected( + $result, + string $driver, + string $configName, + string $dql, + string $sql, + string $dataset, + int $phpVersion, + Type $inferredType, + Type $expectedFirstItemType + ): void + { + $firstResult = reset($result); + $realFirstResult = var_export($firstResult, true); + + self::assertTrue($inferredType->isConstantArray()->yes()); + $inferredFirstItemType = $inferredType->getIterableValueType(); + + self::assertTrue( + $expectedFirstItemType->accepts($inferredFirstItemType, true)->yes(), + sprintf( + "Mismatch between inferred result and expected type\n\nDriver: %s\nConfig: %s\nDataset: %s\nDQL: %s\nSQL: %s\nPHP: %s\nReal first result: %s\nFirst item inferred as: %s\nFirst item expected type: %s\n", + $driver, + $configName, + $dataset, + $dql, + $sql, + $this->getHumanReadablePhpVersion($phpVersion), + $realFirstResult, + $inferredFirstItemType->describe(VerbosityLevel::precise()), + $expectedFirstItemType->describe(VerbosityLevel::precise()), + ), + ); + } + + /** + * @return array + */ + private function getConnectionParamsForDriver(string $driver): array + { + switch ($driver) { + case 'pdo_mysql': + case 'mysqli': + return [ + 'host' => getenv('MYSQL_HOST'), + 'user' => 'root', + 'password' => 'secret', + 'dbname' => 'foo', + ]; + case 'pdo_pgsql': + case 'pgsql': + return [ + 'host' => getenv('PGSQL_HOST'), + 'user' => 'root', + 'password' => 'secret', + 'dbname' => 'foo', + ]; + case 'pdo_sqlite': + case 'sqlite3': + return [ + 'memory' => true, + 'dbname' => 'foo', + ]; + case 'pdo_sqlsrv': + case 'sqlsrv': + return [ + 'host' => getenv('MSSQL_HOST'), + 'user' => 'SA', + 'password' => 'Secret.123', + // user database is created after connection + ]; + default: + throw new LogicException('Unknown driver: ' . $driver); + } + } + + private function getSampleServerVersionForDriver(string $driver): string + { + switch ($driver) { + case 'pdo_mysql': + case 'mysqli': + return '8.0.0'; + case 'pdo_pgsql': + case 'pgsql': + return '13.0.0'; + case 'pdo_sqlite': + case 'sqlite3': + return '3.0.0'; + case 'pdo_sqlsrv': + case 'sqlsrv': + return '15.0.0'; + default: + throw new LogicException('Unknown driver: ' . $driver); + } + } + + private static function bool(): Type + { + return new BooleanType(); + } + + private static function boolOrNull(): Type + { + return TypeCombinator::addNull(new BooleanType()); + } + + private static function numericString(bool $lowercase = false, bool $uppercase = false): Type + { + $types = [ + new StringType(), + new AccessoryNumericStringType(), + ]; + if ($lowercase) { + $types[] = new AccessoryLowercaseStringType(); + } + if ($uppercase) { + $types[] = new AccessoryUppercaseStringType(); + } + + return new IntersectionType($types); + } + + private static function string(): Type + { + return new StringType(); + } + + private static function numericStringOrNull(bool $lowercase = false, bool $uppercase = false): Type + { + return TypeCombinator::addNull(self::numericString($lowercase, $uppercase)); + } + + private static function int(): Type + { + return new IntegerType(); + } + + private static function intNonNegative(): Type + { + return IntegerRangeType::fromInterval(0, null); + } + + private static function intNonNegativeOrNull(): Type + { + return TypeCombinator::addNull(IntegerRangeType::fromInterval(0, null)); + } + + private static function intOrNull(): Type + { + return TypeCombinator::addNull(new IntegerType()); + } + + private static function stringOrNull(): Type + { + return TypeCombinator::addNull(new StringType()); + } + + private static function float(): Type + { + return new FloatType(); + } + + private static function floatOrInt(): Type + { + return TypeCombinator::union(self::float(), self::int()); + } + + private static function floatOrIntOrNull(): Type + { + return TypeCombinator::addNull(self::floatOrInt()); + } + + private static function mixed(): Type + { + return new MixedType(); + } + + private static function floatOrNull(): Type + { + return TypeCombinator::addNull(new FloatType()); + } + + /** + * @return array> + */ + public static function dataNone(): array + { + return []; + } + + /** + * @return array> + */ + public static function dataDefault(): array + { + return [ + [ + 'id' => '1', + 'col_bool' => true, + 'col_bool_nullable' => null, + 'col_float' => 0.125, + 'col_float_nullable' => null, + 'col_decimal' => '0.1', + 'col_decimal_nullable' => null, + 'col_int' => 9, + 'col_int_nullable' => null, + 'col_bigint' => '2147483648', + 'col_bigint_nullable' => null, + 'col_string' => 'foobar', + 'col_string_nullable' => null, + 'col_mixed' => 1, + 'col_datetime' => new DateTime('2024-01-31 12:59:59'), + ], + ]; + } + + /** + * @return array> + */ + public static function dataAllIntLike(): array + { + return [ + [ + 'id' => '1', + 'col_bool' => true, + 'col_bool_nullable' => null, + 'col_float' => 1, + 'col_float_nullable' => null, + 'col_decimal' => '1', + 'col_decimal_nullable' => null, + 'col_int' => 1, + 'col_int_nullable' => null, + 'col_bigint' => '1', + 'col_bigint_nullable' => null, + 'col_string' => '1', + 'col_string_nullable' => null, + 'col_mixed' => 1, + 'col_datetime' => new DateTime('2024-01-31 12:59:59'), + ], + ]; + } + + /** + * @return array> + */ + public static function dataSqrt(): array + { + return [ + [ + 'id' => '1', + 'col_bool' => true, + 'col_bool_nullable' => null, + 'col_float' => 1.0, + 'col_float_nullable' => null, + 'col_decimal' => '1.0', + 'col_decimal_nullable' => null, + 'col_int' => 9, + 'col_int_nullable' => null, + 'col_bigint' => '90000000000', + 'col_bigint_nullable' => null, + 'col_string' => 'foobar', + 'col_string_nullable' => null, + 'col_mixed' => 1, + 'col_datetime' => new DateTime('2024-01-31 12:59:59'), + ], + ]; + } + + private static function stringifyType(Type $type): Type + { + return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if ($type instanceof IntegerType) { + return $type->toString(); + } + + if ($type instanceof FloatType) { + return self::numericString(); + } + + if ($type instanceof BooleanType) { + return $type->toInteger()->toString(); + } + + return $traverse($type); + }); + } + + private function resolveDefaultStringification(?string $driver, int $php, string $configName): bool + { + if ($configName === self::CONFIG_DEFAULT) { + if ($php < 80100) { + return $driver === DriverDetector::PDO_MYSQL || $driver === DriverDetector::PDO_SQLITE; + } + + return false; + } + + if ($configName === self::CONFIG_STRINGIFY || $configName === self::CONFIG_STRINGIFY_NO_EMULATE) { + return $driver === DriverDetector::PDO_PGSQL + || $driver === DriverDetector::PDO_MYSQL + || $driver === DriverDetector::PDO_SQLITE; + } + + if ($configName === self::CONFIG_NO_EMULATE) { + return false; + } + + throw new LogicException('Unknown config name: ' . $configName); + } + + private function resolveDefaultBooleanStringification(?string $driver, int $php, string $configName): bool + { + if ($php < 80100 && $driver === DriverDetector::PDO_PGSQL) { + return false; // pdo_pgsql does not stringify booleans even with ATTR_STRINGIFY_FETCHES prior to PHP 8.1 + } + + return $this->resolveDefaultStringification($driver, $php, $configName); + } + + private function resolveDefaultFloatStringification(?string $driver, int $php, string $configName): bool + { + if ($php < 80400 && $driver === DriverDetector::PDO_PGSQL) { + return true; // pdo_pgsql does stringify floats even without ATTR_STRINGIFY_FETCHES prior to PHP 8.4 + } + + return $this->resolveDefaultStringification($driver, $php, $configName); + } + + private function getHumanReadablePhpVersion(int $phpVersion): string + { + return floor($phpVersion / 10000) . '.' . floor(($phpVersion % 10000) / 100); + } + + private static function hasDbal4(): bool + { + if (!class_exists(InstalledVersions::class)) { + return false; + } + + return InstalledVersions::satisfies(new VersionParser(), 'doctrine/dbal', '4.*'); + } + + private function shouldStringify(string $stringification, ?string $driverType, int $phpVersion, string $configName): bool + { + if ($stringification === self::STRINGIFY_NONE) { + return false; + } + + if ($stringification === self::STRINGIFY_DEFAULT) { + return $this->resolveDefaultStringification($driverType, $phpVersion, $configName); + } + + if ($stringification === self::STRINGIFY_PG_BOOL) { + return $this->resolveDefaultBooleanStringification($driverType, $phpVersion, $configName); + } + + if ($stringification === self::STRINGIFY_PG_FLOAT) { + return $this->resolveDefaultFloatStringification($driverType, $phpVersion, $configName); + } + + throw new LogicException('Unknown stringification: ' . $stringification); + } + + /** + * @param Query $query + * @return Query + */ + private function cloneQueryAndInjectConnectionWithUnknownPdoMysqlDriver(Query $query): Query + { + if ($query->getDQL() === null) { + throw new LogicException('Query does not have DQL'); + } + + $connection = DriverManager::getConnection([ + 'driverClass' => UnknownDriver::class, + 'serverVersion' => $this->getSampleServerVersionForDriver('pdo_mysql'), + ]); + + $entityManager = new EntityManager($connection, $this->createOrmConfig()); + $newQuery = new Query($entityManager); + $newQuery->setDQL($query->getDQL()); + return $newQuery; + } + + private function createOrmConfig(): Configuration + { + $config = new Configuration(); + $config->setProxyNamespace('PHPstan\Doctrine\OrmMatrixProxies'); + $config->setProxyDir('/tmp/doctrine'); + $config->setAutoGenerateProxyClasses(false); + $config->setSecondLevelCacheEnabled(false); + $config->setMetadataCache(new ArrayCachePool()); + + if (InstalledVersions::satisfies(new VersionParser(), 'doctrine/orm', '3.*')) { + $config->setMetadataDriverImpl(new AttributeDriver([__DIR__ . '/Entity'])); + } else { + $config->setMetadataDriverImpl(new AnnotationDriver(new AnnotationReader(), [__DIR__ . '/Entity'])); + } + + $config->addCustomStringFunction('INT_PI', TypedExpressionIntegerPiFunction::class); + $config->addCustomStringFunction('BOOL_PI', TypedExpressionBooleanPiFunction::class); + $config->addCustomStringFunction('STRING_PI', TypedExpressionStringPiFunction::class); + $config->addCustomStringFunction('INT_WRAP', TypedExpressionIntegerWrapFunction::class); + + return $config; + } + + private function determineTypeForUnknownDriverUnknownSetup(Type $originalExpectedType, string $stringify): Type + { + if ($stringify === self::STRINGIFY_NONE) { + return $originalExpectedType; // those are direct column fetches, those always work (this is mild abuse of this flag) + } + + return new MixedType(); + } + +} diff --git a/tests/Platform/README.md b/tests/Platform/README.md new file mode 100644 index 00000000..06d8b843 --- /dev/null +++ b/tests/Platform/README.md @@ -0,0 +1,29 @@ + +## How to run platform tests in docker + +Set current working directory to project root. + +```sh +# Init services & dependencies +- `printf "UID=$(id -u)\nGID=$(id -g)" > .env` +- `docker-compose -f tests/Platform/docker/docker-compose.yml up -d` + +# Test behaviour for PHP 8.0 (old stringification) +- `docker-compose -f tests/Platform/docker/docker-compose.yml run --rm php80 composer update` +- `docker-compose -f tests/Platform/docker/docker-compose.yml run --rm php80 php -d memory_limit=1G vendor/bin/phpunit --group=platform` + +# Test behaviour for PHP 8.1 (adjusted stringification) +- `docker-compose -f tests/Platform/docker/docker-compose.yml run --rm php81 composer update` +- `docker-compose -f tests/Platform/docker/docker-compose.yml run --rm php81 php -d memory_limit=1G vendor/bin/phpunit --group=platform` + +# Test behaviour for PHP 8.4 (pdo_pgsql float stringification fix) +- `docker-compose -f tests/Platform/docker/docker-compose.yml run --rm php84 composer update` +- `docker-compose -f tests/Platform/docker/docker-compose.yml run --rm php84 php -d memory_limit=1G vendor/bin/phpunit --group=platform` +``` + +You can also run utilize those containers for PHPStorm PHPUnit configuration. + +Since the dataset is huge and takes few minutes to run, you can filter only functions you are interested in: +```sh +docker-compose -f tests/Platform/docker/docker-compose.yml run --rm php84 php -d memory_limit=1G vendor/bin/phpunit --group=platform --filter "AVG" +``` diff --git a/tests/Platform/TypedExpressionBooleanPiFunction.php b/tests/Platform/TypedExpressionBooleanPiFunction.php new file mode 100644 index 00000000..ab7049e7 --- /dev/null +++ b/tests/Platform/TypedExpressionBooleanPiFunction.php @@ -0,0 +1,33 @@ +match(TokenType::T_IDENTIFIER); + $parser->match(TokenType::T_OPEN_PARENTHESIS); + $parser->match(TokenType::T_CLOSE_PARENTHESIS); + } + + public function getReturnType(): Type + { + return Type::getType(Types::BOOLEAN); + } + +} diff --git a/tests/Platform/TypedExpressionIntegerPiFunction.php b/tests/Platform/TypedExpressionIntegerPiFunction.php new file mode 100644 index 00000000..57f4a2bd --- /dev/null +++ b/tests/Platform/TypedExpressionIntegerPiFunction.php @@ -0,0 +1,33 @@ +match(TokenType::T_IDENTIFIER); + $parser->match(TokenType::T_OPEN_PARENTHESIS); + $parser->match(TokenType::T_CLOSE_PARENTHESIS); + } + + public function getReturnType(): Type + { + return Type::getType(Types::INTEGER); + } + +} diff --git a/tests/Platform/TypedExpressionIntegerWrapFunction.php b/tests/Platform/TypedExpressionIntegerWrapFunction.php new file mode 100644 index 00000000..e46d39bc --- /dev/null +++ b/tests/Platform/TypedExpressionIntegerWrapFunction.php @@ -0,0 +1,38 @@ +walkArithmeticPrimary($this->expr) . ')'; + } + + public function parse(Parser $parser): void + { + $parser->match(TokenType::T_IDENTIFIER); + $parser->match(TokenType::T_OPEN_PARENTHESIS); + $this->expr = $parser->ArithmeticPrimary(); + $parser->match(TokenType::T_CLOSE_PARENTHESIS); + } + + public function getReturnType(): Type + { + return Type::getType(Types::INTEGER); + } + +} diff --git a/tests/Platform/TypedExpressionStringPiFunction.php b/tests/Platform/TypedExpressionStringPiFunction.php new file mode 100644 index 00000000..1567ca60 --- /dev/null +++ b/tests/Platform/TypedExpressionStringPiFunction.php @@ -0,0 +1,33 @@ +match(TokenType::T_IDENTIFIER); + $parser->match(TokenType::T_OPEN_PARENTHESIS); + $parser->match(TokenType::T_CLOSE_PARENTHESIS); + } + + public function getReturnType(): Type + { + return Type::getType(Types::STRING); + } + +} diff --git a/tests/Platform/UnknownDriver.php b/tests/Platform/UnknownDriver.php new file mode 100644 index 00000000..52c408e0 --- /dev/null +++ b/tests/Platform/UnknownDriver.php @@ -0,0 +1,26 @@ +analyse([__DIR__ . '/data/unused-private-property.php'], [ [ 'Property PHPStan\Rules\Doctrine\ORM\UnusedPrivateProperty\EntityWithAGeneratedId::$unused is never written, only read.', diff --git a/tests/Rules/DeadCode/entity-manager.php b/tests/Rules/DeadCode/entity-manager.php index bc0d5ccb..30eeec97 100644 --- a/tests/Rules/DeadCode/entity-manager.php +++ b/tests/Rules/DeadCode/entity-manager.php @@ -17,12 +17,12 @@ $metadataDriver = new MappingDriverChain(); $metadataDriver->addDriver(new AnnotationDriver( new AnnotationReader(), - [__DIR__ . '/data'] + [__DIR__ . '/data'], ), 'PHPStan\\Rules\\Doctrine\\ORM\\'); if (PHP_VERSION_ID >= 80100) { $metadataDriver->addDriver( new AttributeDriver([__DIR__ . '/data']), - 'PHPStan\\Rules\\Doctrine\\ORMAttributes\\' + 'PHPStan\\Rules\\Doctrine\\ORMAttributes\\', ); } @@ -33,5 +33,5 @@ 'driver' => 'pdo_sqlite', 'memory' => true, ]), - $config + $config, ); diff --git a/tests/Rules/Doctrine/ORM/DqlRuleTest.php b/tests/Rules/Doctrine/ORM/DqlRuleTest.php index 007ed40b..6e019ebc 100644 --- a/tests/Rules/Doctrine/ORM/DqlRuleTest.php +++ b/tests/Rules/Doctrine/ORM/DqlRuleTest.php @@ -11,6 +11,7 @@ /** * @extends RuleTestCase + * @runInSeparateProcess */ class DqlRuleTest extends RuleTestCase { @@ -52,4 +53,10 @@ public function testRule(): void ]); } + protected function shouldFailOnPhpErrors(): bool + { + // doctrine/orm/src/Query/Parser.php throws assert($this->lexer->lookahead !== null) + return false; + } + } diff --git a/tests/Rules/Doctrine/ORM/EntityColumnRuleTest.php b/tests/Rules/Doctrine/ORM/EntityColumnRuleTest.php index 57561ef4..5fd80dcc 100644 --- a/tests/Rules/Doctrine/ORM/EntityColumnRuleTest.php +++ b/tests/Rules/Doctrine/ORM/EntityColumnRuleTest.php @@ -7,6 +7,7 @@ use Composer\InstalledVersions; use Doctrine\DBAL\Types\Type; use Iterator; +use PHPStan\Doctrine\Driver\DriverDetector; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; use PHPStan\Type\Doctrine\DefaultDescriptorRegistry; @@ -17,28 +18,34 @@ use PHPStan\Type\Doctrine\Descriptors\DateTimeType; use PHPStan\Type\Doctrine\Descriptors\DateType; use PHPStan\Type\Doctrine\Descriptors\DecimalType; +use PHPStan\Type\Doctrine\Descriptors\EnumType; use PHPStan\Type\Doctrine\Descriptors\IntegerType; use PHPStan\Type\Doctrine\Descriptors\JsonType; -use PHPStan\Type\Doctrine\Descriptors\Ramsey\UuidTypeDescriptor; +use PHPStan\Type\Doctrine\Descriptors\Ramsey\UuidTypeDescriptor as RamseyUuidTypeDescriptor; use PHPStan\Type\Doctrine\Descriptors\ReflectionDescriptor; use PHPStan\Type\Doctrine\Descriptors\SimpleArrayType; use PHPStan\Type\Doctrine\Descriptors\StringType; +use PHPStan\Type\Doctrine\Descriptors\Symfony\UlidTypeDescriptor as SymfonyUlidTypeDescriptor; +use PHPStan\Type\Doctrine\Descriptors\Symfony\UuidTypeDescriptor as SymfonyUuidTypeDescriptor; use PHPStan\Type\Doctrine\ObjectMetadataResolver; use function array_unshift; +use function class_exists; +use function sprintf; use function strpos; use const PHP_VERSION_ID; /** * @extends RuleTestCase + * @runInSeparateProcess */ class EntityColumnRuleTest extends RuleTestCase { - /** @var bool */ - private $allowNullablePropertyForRequiredField; + private bool $allowNullablePropertyForRequiredField; - /** @var string|null */ - private $objectManagerLoader; + private ?string $objectManagerLoader = null; + + private bool $useSymfonyUuid = false; protected function getRule(): Rule { @@ -48,8 +55,23 @@ protected function getRule(): Rule if (!Type::hasType(CustomNumericType::NAME)) { Type::addType(CustomNumericType::NAME, CustomNumericType::class); } - if (!Type::hasType(FakeTestingUuidType::NAME)) { - Type::addType(FakeTestingUuidType::NAME, FakeTestingUuidType::class); + if ($this->useSymfonyUuid) { + if (!Type::hasType(FakeTestingSymfonyUuidType::NAME)) { + Type::addType(FakeTestingSymfonyUuidType::NAME, FakeTestingSymfonyUuidType::class); + } else { + // Override Ramsay definition + Type::overrideType(FakeTestingSymfonyUuidType::NAME, FakeTestingSymfonyUuidType::class); + } + if (!Type::hasType(FakeTestingSymfonyUlidType::NAME)) { + Type::addType(FakeTestingSymfonyUlidType::NAME, FakeTestingSymfonyUlidType::class); + } + } else { + if (!Type::hasType(FakeTestingRamseyUuidType::NAME)) { + Type::addType(FakeTestingRamseyUuidType::NAME, FakeTestingRamseyUuidType::class); + } else { + // Override Symfony definition + Type::overrideType(FakeTestingRamseyUuidType::NAME, FakeTestingRamseyUuidType::class); + } } if (!Type::hasType('carbon')) { Type::addType('carbon', CarbonType::class); @@ -70,21 +92,23 @@ protected function getRule(): Rule new DateTimeImmutableType(), new DateTimeType(), new DateType(), - new DecimalType(), + new DecimalType(new DriverDetector()), new JsonType(), new IntegerType(), new StringType(), new SimpleArrayType(), - new UuidTypeDescriptor(FakeTestingUuidType::class), - new ReflectionDescriptor(CarbonImmutableType::class, $this->createBroker()), - new ReflectionDescriptor(CarbonType::class, $this->createBroker()), - new ReflectionDescriptor(CustomType::class, $this->createBroker()), - new ReflectionDescriptor(CustomNumericType::class, $this->createBroker()), + new EnumType(), + new RamseyUuidTypeDescriptor(FakeTestingRamseyUuidType::class), + new SymfonyUuidTypeDescriptor(FakeTestingSymfonyUuidType::class), + new SymfonyUlidTypeDescriptor(FakeTestingSymfonyUlidType::class), + new ReflectionDescriptor(CarbonImmutableType::class, $this->createReflectionProvider(), self::getContainer()), + new ReflectionDescriptor(CarbonType::class, $this->createReflectionProvider(), self::getContainer()), + new ReflectionDescriptor(CustomType::class, $this->createReflectionProvider(), self::getContainer()), + new ReflectionDescriptor(CustomNumericType::class, $this->createReflectionProvider(), self::getContainer()), ]), $this->createReflectionProvider(), true, $this->allowNullablePropertyForRequiredField, - true ); } @@ -161,7 +185,7 @@ public function testRule(?string $objectManagerLoader): void 156, ], [ - 'Property PHPStan\Rules\Doctrine\ORM\MyBrokenEntity::$invalidSimpleArray type mapping mismatch: database can contain array but property expects array.', + 'Property PHPStan\Rules\Doctrine\ORM\MyBrokenEntity::$invalidSimpleArray type mapping mismatch: database can contain list but property expects array.', 162, ], [ @@ -232,7 +256,7 @@ public function testRuleWithAllowedNullableProperty(?string $objectManagerLoader 156, ], [ - 'Property PHPStan\Rules\Doctrine\ORM\MyBrokenEntity::$invalidSimpleArray type mapping mismatch: database can contain array but property expects array.', + 'Property PHPStan\Rules\Doctrine\ORM\MyBrokenEntity::$invalidSimpleArray type mapping mismatch: database can contain list but property expects array.', 162, ], [ @@ -270,9 +294,16 @@ public function testSuperclass(?string $objectManagerLoader): void { $this->allowNullablePropertyForRequiredField = false; $this->objectManagerLoader = $objectManagerLoader; + + $dbalVersion = InstalledVersions::getVersion('doctrine/dbal'); + $hasDbal4 = $dbalVersion !== null && strpos($dbalVersion, '4.') === 0; + $this->analyse([__DIR__ . '/data/MyBrokenSuperclass.php'], [ [ - 'Property PHPStan\Rules\Doctrine\ORM\MyBrokenSuperclass::$five type mapping mismatch: database can contain resource but property expects int.', + sprintf( + 'Property PHPStan\Rules\Doctrine\ORM\MyBrokenSuperclass::$five type mapping mismatch: database can contain %s but property expects int.', + $hasDbal4 ? 'string' : 'resource', + ), 17, ], ]); @@ -390,15 +421,27 @@ public function testEnumType(?string $objectManagerLoader): void $this->analyse([__DIR__ . '/data-attributes/enum-type.php'], [ [ 'Property PHPStan\Rules\Doctrine\ORMAttributes\Foo::$type2 type mapping mismatch: database can contain PHPStan\Rules\Doctrine\ORMAttributes\FooEnum but property expects PHPStan\Rules\Doctrine\ORMAttributes\BarEnum.', - 35, + 42, ], [ 'Property PHPStan\Rules\Doctrine\ORMAttributes\Foo::$type2 type mapping mismatch: property can contain PHPStan\Rules\Doctrine\ORMAttributes\BarEnum but database expects PHPStan\Rules\Doctrine\ORMAttributes\FooEnum.', - 35, + 42, ], [ 'Property PHPStan\Rules\Doctrine\ORMAttributes\Foo::$type3 type mapping mismatch: backing type string of enum PHPStan\Rules\Doctrine\ORMAttributes\FooEnum does not match database type int.', - 38, + 45, + ], + [ + 'Property PHPStan\Rules\Doctrine\ORMAttributes\Foo::$type5 type mapping mismatch: database can contain list but property expects PHPStan\Rules\Doctrine\ORMAttributes\FooEnum.', + 51, + ], + [ + 'Property PHPStan\Rules\Doctrine\ORMAttributes\Foo::$type5 type mapping mismatch: property can contain PHPStan\Rules\Doctrine\ORMAttributes\FooEnum but database expects array.', + 51, + ], + [ + 'Property PHPStan\Rules\Doctrine\ORMAttributes\Foo::$type7 type mapping mismatch: backing type int of enum PHPStan\Rules\Doctrine\ORMAttributes\BazEnum does not match value type string of the database type array.', + 63, ], ]); } @@ -431,4 +474,105 @@ public function testBug306(?string $objectManagerLoader): void ]); } + /** + * @dataProvider dataObjectManagerLoader + */ + public function testBug677(?string $objectManagerLoader): void + { + if (PHP_VERSION_ID < 80100) { + self::markTestSkipped('Test requires PHP 8.1'); + } + if (!class_exists(\Doctrine\DBAL\Types\EnumType::class)) { + self::markTestSkipped('Test requires EnumType.'); + } + + $this->allowNullablePropertyForRequiredField = false; + $this->objectManagerLoader = $objectManagerLoader; + $this->analyse([__DIR__ . '/data/bug-677.php'], []); + } + + /** + * @dataProvider dataObjectManagerLoader + */ + public function testSymfonyUuid(?string $objectManagerLoader): void + { + $this->allowNullablePropertyForRequiredField = true; + $this->objectManagerLoader = $objectManagerLoader; + $this->useSymfonyUuid = true; + + $this->analyse([__DIR__ . '/data/EntityWithSymfonyUid.php'], [ + [ + 'Property PHPStan\Rules\Doctrine\ORM\EntityWithSymfonyUid::$uuidInvalidType type mapping mismatch: database can contain Symfony\Component\Uid\Uuid but property expects string.', + 32, + ], + [ + 'Property PHPStan\Rules\Doctrine\ORM\EntityWithSymfonyUid::$ulidInvalidType type mapping mismatch: database can contain Symfony\Component\Uid\Ulid but property expects string.', + 44, + ], + ]); + } + + /** + * @dataProvider dataObjectManagerLoader + */ + public function testBug679(?string $objectManagerLoader): void + { + if (PHP_VERSION_ID < 80100) { + self::markTestSkipped('Test requires PHP 8.1'); + } + if (!class_exists(\Doctrine\DBAL\Types\EnumType::class)) { + self::markTestSkipped('Test requires EnumType.'); + } + + $this->allowNullablePropertyForRequiredField = false; + $this->objectManagerLoader = $objectManagerLoader; + $this->analyse([__DIR__ . '/data/bug-679.php'], []); + } + + /** + * @dataProvider dataObjectManagerLoader + */ + public function testBugSingleEnum(?string $objectManagerLoader): void + { + if (PHP_VERSION_ID < 80100) { + self::markTestSkipped('Test requires PHP 8.1'); + } + if (!class_exists(\Doctrine\DBAL\Types\EnumType::class)) { + self::markTestSkipped('Test requires EnumType.'); + } + + $this->allowNullablePropertyForRequiredField = false; + $this->objectManagerLoader = $objectManagerLoader; + $this->analyse([__DIR__ . '/data/bug-single-enum.php'], []); + } + + /** + * @dataProvider dataObjectManagerLoader + */ + public function testBug659(?string $objectManagerLoader): void + { + $this->allowNullablePropertyForRequiredField = false; + $this->objectManagerLoader = $objectManagerLoader; + + $dbalVersion = InstalledVersions::getVersion('doctrine/dbal'); + $hasDbal4 = $dbalVersion !== null && strpos($dbalVersion, '4.') === 0; + if ($hasDbal4) { + $errors = [ + [ + 'Property PHPStan\Rules\Doctrine\ORM\MyEntity659::$binaryResource type mapping mismatch: database can contain string but property expects resource.', + 31, + ], + ]; + } else { + $errors = [ + [ + 'Property PHPStan\Rules\Doctrine\ORM\MyEntity659::$binaryString type mapping mismatch: database can contain resource but property expects string.', + 25, + ], + ]; + } + + $this->analyse([__DIR__ . '/data/bug-659.php'], $errors); + } + } diff --git a/tests/Rules/Doctrine/ORM/EntityConstructorNotFinalRuleTest.php b/tests/Rules/Doctrine/ORM/EntityConstructorNotFinalRuleTest.php index 7647704b..5427f788 100644 --- a/tests/Rules/Doctrine/ORM/EntityConstructorNotFinalRuleTest.php +++ b/tests/Rules/Doctrine/ORM/EntityConstructorNotFinalRuleTest.php @@ -9,17 +9,17 @@ /** * @extends RuleTestCase + * @runInSeparateProcess */ class EntityConstructorNotFinalRuleTest extends RuleTestCase { - /** @var string|null */ - private $objectManagerLoader; + private ?string $objectManagerLoader = null; protected function getRule(): Rule { return new EntityConstructorNotFinalRule( - new ObjectMetadataResolver($this->objectManagerLoader, __DIR__ . '/../../../../tmp') + new ObjectMetadataResolver($this->objectManagerLoader, __DIR__ . '/../../../../tmp'), ); } diff --git a/tests/Rules/Doctrine/ORM/EntityMappingExceptionRuleTest.php b/tests/Rules/Doctrine/ORM/EntityMappingExceptionRuleTest.php index 26a4e744..b5881adb 100644 --- a/tests/Rules/Doctrine/ORM/EntityMappingExceptionRuleTest.php +++ b/tests/Rules/Doctrine/ORM/EntityMappingExceptionRuleTest.php @@ -9,6 +9,7 @@ /** * @extends RuleTestCase + * @runInSeparateProcess */ class EntityMappingExceptionRuleTest extends RuleTestCase { @@ -16,7 +17,7 @@ class EntityMappingExceptionRuleTest extends RuleTestCase protected function getRule(): Rule { return new EntityMappingExceptionRule( - new ObjectMetadataResolver(__DIR__ . '/entity-manager.php', __DIR__ . '/../../../../tmp') + new ObjectMetadataResolver(__DIR__ . '/entity-manager.php', __DIR__ . '/../../../../tmp'), ); } diff --git a/tests/Rules/Doctrine/ORM/EntityNotFinalRuleTest.php b/tests/Rules/Doctrine/ORM/EntityNotFinalRuleTest.php index 9f1494a7..a1474f56 100644 --- a/tests/Rules/Doctrine/ORM/EntityNotFinalRuleTest.php +++ b/tests/Rules/Doctrine/ORM/EntityNotFinalRuleTest.php @@ -9,17 +9,17 @@ /** * @extends RuleTestCase + * @runInSeparateProcess */ class EntityNotFinalRuleTest extends RuleTestCase { - /** @var string|null */ - private $objectManagerLoader; + private ?string $objectManagerLoader = null; protected function getRule(): Rule { return new EntityNotFinalRule( - new ObjectMetadataResolver($this->objectManagerLoader, __DIR__ . '/../../../../tmp') + new ObjectMetadataResolver($this->objectManagerLoader, __DIR__ . '/../../../../tmp'), ); } diff --git a/tests/Rules/Doctrine/ORM/EntityRelationRuleTest.php b/tests/Rules/Doctrine/ORM/EntityRelationRuleTest.php index cd83bebe..477b9157 100644 --- a/tests/Rules/Doctrine/ORM/EntityRelationRuleTest.php +++ b/tests/Rules/Doctrine/ORM/EntityRelationRuleTest.php @@ -10,22 +10,20 @@ /** * @extends RuleTestCase + * @runInSeparateProcess */ class EntityRelationRuleTest extends RuleTestCase { - /** @var bool */ - private $allowNullablePropertyForRequiredField; + private bool $allowNullablePropertyForRequiredField; - /** @var string|null */ - private $objectManagerLoader; + private ?string $objectManagerLoader = null; protected function getRule(): Rule { return new EntityRelationRule( new ObjectMetadataResolver($this->objectManagerLoader, __DIR__ . '/../../../../tmp'), $this->allowNullablePropertyForRequiredField, - true ); } diff --git a/tests/Rules/Doctrine/ORM/FakeTestingUuidType.php b/tests/Rules/Doctrine/ORM/FakeTestingRamseyUuidType.php similarity index 90% rename from tests/Rules/Doctrine/ORM/FakeTestingUuidType.php rename to tests/Rules/Doctrine/ORM/FakeTestingRamseyUuidType.php index fcb28f7e..51594f97 100644 --- a/tests/Rules/Doctrine/ORM/FakeTestingUuidType.php +++ b/tests/Rules/Doctrine/ORM/FakeTestingRamseyUuidType.php @@ -13,7 +13,7 @@ * From https://github.com/ramsey/uuid-doctrine/blob/fafebbe972cdaba9274c286ea8923e2de2579027/src/UuidType.php * Copyright (c) 2012-2022 Ben Ramsey */ -final class FakeTestingUuidType extends GuidType +final class FakeTestingRamseyUuidType extends GuidType { public const NAME = 'uuid'; @@ -47,6 +47,7 @@ public function convertToDatabaseValue($value, AbstractPlatform $platform): ?str return null; } + /** @throws ConversionException */ return (string) $value; } @@ -55,7 +56,7 @@ public function getName(): string return self::NAME; } - public function requiresSQLCommentHint(AbstractPlatform $platform): bool + public function requiresSQLCommentHint(AbstractPlatform $platform): bool // @phpstan-ignore return.tooWideBool { return true; } diff --git a/tests/Rules/Doctrine/ORM/FakeTestingSymfonyUlidType.php b/tests/Rules/Doctrine/ORM/FakeTestingSymfonyUlidType.php new file mode 100644 index 00000000..bd6b87c0 --- /dev/null +++ b/tests/Rules/Doctrine/ORM/FakeTestingSymfonyUlidType.php @@ -0,0 +1,113 @@ + + */ +final class FakeTestingSymfonyUlidType extends Type +{ + + public const NAME = 'ulid'; + + /** + * @not-deprecated + */ + public function getName(): string + { + return self::NAME; + } + + protected function getUidClass(): string + { + return Ulid::class; + } + + /** + * {@inheritdoc} + */ + public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + { + if ($this->hasNativeGuidType($platform)) { + return $platform->getGuidTypeDeclarationSQL($column); + } + + return $platform->getBinaryTypeDeclarationSQL([ + 'length' => '16', + 'fixed' => true, + ]); + } + + /** + * {@inheritdoc} + * + * @throws ConversionException + */ + public function convertToPHPValue($value, AbstractPlatform $platform): ?AbstractUid + { + if ($value instanceof AbstractUid || $value === null) { + return $value; + } + + if (!is_string($value)) { + throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', 'string', AbstractUid::class]); + } + + try { + /** @phpstan-ignore-next-line method.dynamicName */ + return $this->getUidClass()::fromString($value); + } catch (InvalidArgumentException $e) { + throw ConversionException::conversionFailed($value, $this->getName(), $e); + } + } + + /** + * {@inheritdoc} + * + * @throws ConversionException + */ + public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string + { + $toString = $this->hasNativeGuidType($platform) ? 'toRfc4122' : 'toBinary'; + + if ($value instanceof AbstractUid) { + /** @phpstan-ignore-next-line method.dynamicName */ + return $value->$toString(); + } + + if ($value === null || $value === '') { + return null; + } + + if (!is_string($value)) { + throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', 'string', AbstractUid::class]); + } + + try { + /** @phpstan-ignore-next-line method.dynamicName */ + return $this->getUidClass()::fromString($value)->$toString(); + } catch (InvalidArgumentException $e) { + throw ConversionException::conversionFailed($value, $this->getName()); + } + } + + public function requiresSQLCommentHint(AbstractPlatform $platform): bool // @phpstan-ignore return.tooWideBool + { + return true; + } + + private function hasNativeGuidType(AbstractPlatform $platform): bool + { + return $platform->getGuidTypeDeclarationSQL([]) !== $platform->getStringTypeDeclarationSQL(['fixed' => true, 'length' => 36]); + } + +} diff --git a/tests/Rules/Doctrine/ORM/FakeTestingSymfonyUuidType.php b/tests/Rules/Doctrine/ORM/FakeTestingSymfonyUuidType.php new file mode 100644 index 00000000..8217af22 --- /dev/null +++ b/tests/Rules/Doctrine/ORM/FakeTestingSymfonyUuidType.php @@ -0,0 +1,113 @@ + + */ +final class FakeTestingSymfonyUuidType extends Type +{ + + public const NAME = 'uuid'; + + /** + * @not-deprecated + */ + public function getName(): string + { + return self::NAME; + } + + protected function getUidClass(): string + { + return Uuid::class; + } + + /** + * {@inheritdoc} + */ + public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + { + if ($this->hasNativeGuidType($platform)) { + return $platform->getGuidTypeDeclarationSQL($column); + } + + return $platform->getBinaryTypeDeclarationSQL([ + 'length' => '16', + 'fixed' => true, + ]); + } + + /** + * {@inheritdoc} + * + * @throws ConversionException + */ + public function convertToPHPValue($value, AbstractPlatform $platform): ?AbstractUid + { + if ($value instanceof AbstractUid || $value === null) { + return $value; + } + + if (!is_string($value)) { + throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', 'string', AbstractUid::class]); + } + + try { + /** @phpstan-ignore-next-line method.dynamicName */ + return $this->getUidClass()::fromString($value); + } catch (InvalidArgumentException $e) { + throw ConversionException::conversionFailed($value, $this->getName(), $e); + } + } + + /** + * {@inheritdoc} + * + * @throws ConversionException + */ + public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string + { + $toString = $this->hasNativeGuidType($platform); + + if ($value instanceof AbstractUid) { + /** @phpstan-ignore-next-line method.dynamicName */ + return $value->$toString(); + } + + if ($value === null || $value === '') { + return null; + } + + if (!is_string($value)) { + throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', 'string', AbstractUid::class]); + } + + try { + /** @phpstan-ignore-next-line method.dynamicName */ + return $this->getUidClass()::fromString($value)->$toString(); + } catch (InvalidArgumentException $e) { + throw ConversionException::conversionFailed($value, $this->getName()); + } + } + + public function requiresSQLCommentHint(AbstractPlatform $platform): bool // @phpstan-ignore return.tooWideBool + { + return true; + } + + private function hasNativeGuidType(AbstractPlatform $platform): bool + { + return $platform->getGuidTypeDeclarationSQL([]) !== $platform->getStringTypeDeclarationSQL(['fixed' => true, 'length' => 36]); + } + +} diff --git a/tests/Rules/Doctrine/ORM/QueryBuilderDqlRuleSlowTest.php b/tests/Rules/Doctrine/ORM/QueryBuilderDqlRuleSlowTest.php deleted file mode 100644 index e633b464..00000000 --- a/tests/Rules/Doctrine/ORM/QueryBuilderDqlRuleSlowTest.php +++ /dev/null @@ -1,137 +0,0 @@ - - */ -class QueryBuilderDqlRuleSlowTest extends RuleTestCase -{ - - protected function getRule(): Rule - { - return new QueryBuilderDqlRule( - new ObjectMetadataResolver(__DIR__ . '/entity-manager.php', __DIR__ . '/../../../../tmp'), - true - ); - } - - public function testRule(): void - { - $this->analyse([__DIR__ . '/data/query-builder-dql.php'], [ - [ - "QueryBuilder: [Syntax Error] line 0, col 66: Error: Expected end of string, got ')'\nDQL: SELECT e FROM PHPStan\Rules\Doctrine\ORM\MyEntity e WHERE e.id = 1)", - 31, - ], - [ - "QueryBuilder: [Syntax Error] line 0, col 68: Error: Expected end of string, got ')'\nDQL: SELECT e FROM PHPStan\Rules\Doctrine\ORM\MyEntity e WHERE e.id = :id)", - 43, - ], - [ - "QueryBuilder: [Syntax Error] line 0, col 68: Error: Expected end of string, got ')'\nDQL: SELECT e FROM PHPStan\Rules\Doctrine\ORM\MyEntity e WHERE e.id = :id)", - 55, - ], - [ - 'QueryBuilder: [Semantical Error] line 0, col 60 near \'transient = \': Error: Class PHPStan\Rules\Doctrine\ORM\MyEntity has no field or association named transient', - 62, - ], - [ - 'QueryBuilder: [Semantical Error] line 0, col 14 near \'Foo e\': Error: Class \'Foo\' is not defined.', - 71, - ], - [ - 'Could not analyse QueryBuilder with dynamic arguments.', - 99, - ], - [ - 'QueryBuilder: [Semantical Error] line 0, col 60 near \'transient = \': Error: Class PHPStan\Rules\Doctrine\ORM\MyEntity has no field or association named transient', - 107, - ], - [ - "QueryBuilder: [Syntax Error] line 0, col 82: Error: Expected end of string, got ')'\nDQL: SELECT e FROM PHPStan\Rules\Doctrine\ORM\MyEntity e WHERE e.id = 1 ORDER BY e.name) ASC", - 129, - ], - [ - 'QueryBuilder: [Semantical Error] line 0, col 78 near \'name ASC\': Error: Class PHPStan\Rules\Doctrine\ORM\MyEntity has no field or association named name', - 139, - ], - [ - 'QueryBuilder: [Semantical Error] line 0, col 78 near \'name ASC\': Error: Class PHPStan\Rules\Doctrine\ORM\MyEntity has no field or association named name', - 160, - ], - [ - 'QueryBuilder: [Semantical Error] line 0, col 60 near \'transient = 1\': Error: Class PHPStan\Rules\Doctrine\ORM\MyEntity has no field or association named transient', - 170, - ], - [ - 'QueryBuilder: [Semantical Error] line 0, col 72 near \'nickname LIKE\': Error: Class PHPStan\Rules\Doctrine\ORM\MyEntity has no field or association named nickname', - 194, - ], - [ - 'QueryBuilder: [Semantical Error] line 0, col 72 near \'nickname IS \': Error: Class PHPStan\Rules\Doctrine\ORM\MyEntity has no field or association named nickname', - 206, - ], - [ - "QueryBuilder: [Syntax Error] line 0, col 80: Error: Expected =, <, <=, <>, >, >=, !=, got ')'\nDQL: SELECT e FROM PHPStan\Rules\Doctrine\ORM\MyEntity e WHERE e.id = 1 OR e.nickname) IS NULL", - 218, - ], - [ - 'QueryBuilder: [Semantical Error] line 0, col 60 near \'transient = \': Error: Class PHPStan\Rules\Doctrine\ORM\MyEntity has no field or association named transient', - 234, - ], - [ - 'QueryBuilder: [Semantical Error] line 0, col 60 near \'nonexistent =\': Error: Class PHPStan\Rules\Doctrine\ORM\MyEntity has no field or association named nonexistent', - 251, - ], - [ - "QueryBuilder: [Syntax Error] line 0, col -1: Error: Expected =, <, <=, <>, >, >=, !=, got end of string.\nDQL: SELECT e FROM PHPStan\Rules\Doctrine\ORM\MyEntity e WHERE foo", - 281, - ], - ]); - } - - public function testRuleBranches(): void - { - $errors = [ - [ - 'QueryBuilder: [Semantical Error] line 0, col 58 near \'p.id = 1\': Error: \'p\' is not defined.', - 31, - ], - [ - 'QueryBuilder: [Semantical Error] line 0, col 58 near \'p.id = 1\': Error: \'p\' is not defined.', - 45, - ], - [ - 'QueryBuilder: [Semantical Error] line 0, col 58 near \'p.id = 1\': Error: \'p\' is not defined.', - 59, - ], - [ - 'QueryBuilder: [Semantical Error] line 0, col 93 near \'t.id = 1\': Error: \'t\' is not defined.', - 90, - ], - [ - 'QueryBuilder: [Semantical Error] line 0, col 95 near \'foo = 1\': Error: Class PHPStan\Rules\Doctrine\ORM\MyEntity has no field or association named foo', - 107, - ], - [ - 'QueryBuilder: [Semantical Error] line 0, col 93 near \'t.id = 1\': Error: \'t\' is not defined.', - 107, - ], - ]; - $this->analyse([__DIR__ . '/data/query-builder-branches-dql.php'], $errors); - } - - public static function getAdditionalConfigFiles(): array - { - return [ - __DIR__ . '/../../../../extension.neon', - __DIR__ . '/entity-manager.neon', - __DIR__ . '/slow.neon', - ]; - } - -} diff --git a/tests/Rules/Doctrine/ORM/QueryBuilderDqlRuleTest.php b/tests/Rules/Doctrine/ORM/QueryBuilderDqlRuleTest.php index 28700630..65fd38d6 100644 --- a/tests/Rules/Doctrine/ORM/QueryBuilderDqlRuleTest.php +++ b/tests/Rules/Doctrine/ORM/QueryBuilderDqlRuleTest.php @@ -16,7 +16,7 @@ protected function getRule(): Rule { return new QueryBuilderDqlRule( new ObjectMetadataResolver(__DIR__ . '/entity-manager.php', __DIR__ . '/../../../../tmp'), - true + true, ); } @@ -108,18 +108,22 @@ public function testRuleBranches(): void [ 'QueryBuilder: [Semantical Error] line 0, col 58 near \'p.id = 1\': Error: \'p\' is not defined.', 59, + 'Detected from DQL branch: SELECT e FROM PHPStan\Rules\Doctrine\ORM\MyEntity e WHERE p.id = 1', ], [ 'QueryBuilder: [Semantical Error] line 0, col 93 near \'t.id = 1\': Error: \'t\' is not defined.', 90, + 'Detected from DQL branch: SELECT e FROM PHPStan\Rules\Doctrine\ORM\MyEntity e INNER JOIN e.parent p WHERE p.id = 1 AND t.id = 1', ], [ 'QueryBuilder: [Semantical Error] line 0, col 95 near \'foo = 1\': Error: Class PHPStan\Rules\Doctrine\ORM\MyEntity has no field or association named foo', 107, + 'Detected from DQL branch: SELECT e FROM PHPStan\Rules\Doctrine\ORM\MyEntity e INNER JOIN e.parent p WHERE p.id = 1 AND e.foo = 1', ], [ 'QueryBuilder: [Semantical Error] line 0, col 93 near \'t.id = 1\': Error: \'t\' is not defined.', 107, + 'Detected from DQL branch: SELECT e FROM PHPStan\Rules\Doctrine\ORM\MyEntity e INNER JOIN e.parent p WHERE p.id = 1 AND t.id = 1', ], ]; $this->analyse([__DIR__ . '/data/query-builder-branches-dql.php'], $errors); @@ -146,6 +150,7 @@ public function testBranchingPerformance(): void [ 'QueryBuilder: [Semantical Error] line 0, col 58 near \'p.id = 1 AND\': Error: \'p\' is not defined.', 121, + 'Detected from DQL branch: SELECT e FROM PHPStan\Rules\Doctrine\ORM\MyEntity e WHERE p.id = 1 AND p.id = 3 AND p.id = 5 AND p.id = 7 AND p.id = 9 AND p.id = 11 AND p.id = 13 AND p.id = 15 AND p.id = 15 AND p.id = 15 AND p.id = 15 AND p.id = 15 AND p.id = 15 AND p.id = 15 AND p.id = 15 AND p.id = 15 AND p.id = 15 AND p.id = 15', ], ]); } @@ -171,4 +176,10 @@ public static function getAdditionalConfigFiles(): array ]; } + protected function shouldFailOnPhpErrors(): bool + { + // doctrine/orm/src/Query/Parser.php throws assert(): assert($peek !== null) error + return false; + } + } diff --git a/tests/Rules/Doctrine/ORM/data-attributes/enum-type.php b/tests/Rules/Doctrine/ORM/data-attributes/enum-type.php index a0c3b937..50e0a2ec 100644 --- a/tests/Rules/Doctrine/ORM/data-attributes/enum-type.php +++ b/tests/Rules/Doctrine/ORM/data-attributes/enum-type.php @@ -18,6 +18,13 @@ enum BarEnum: string { } +enum BazEnum: int { + + case ONE = 1; + case TWO = 2; + +} + #[ORM\Entity] class Foo { @@ -40,4 +47,18 @@ class Foo #[ORM\Column] public FooEnum $type4; + #[ORM\Column(type: "simple_array", enumType: FooEnum::class)] + public FooEnum $type5; + + /** + * @var list + */ + #[ORM\Column(type: "simple_array", enumType: FooEnum::class)] + public array $type6; + + /** + * @var list + */ + #[ORM\Column(type: "simple_array", enumType: BazEnum::class)] + public array $type7; } diff --git a/tests/Rules/Doctrine/ORM/data/EntityWithSymfonyUid.php b/tests/Rules/Doctrine/ORM/data/EntityWithSymfonyUid.php new file mode 100644 index 00000000..a1c742e2 --- /dev/null +++ b/tests/Rules/Doctrine/ORM/data/EntityWithSymfonyUid.php @@ -0,0 +1,45 @@ += 8.0 + +namespace PHPStan\Rules\Doctrine\ORM; + +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity() + */ +class MyEntity659 +{ + + /** + * @ORM\Id() + * @ORM\GeneratedValue() + * @ORM\Column(type="integer") + * @var int + */ + private $id; + + /** + * @var string + * @ORM\Column(type="binary") + */ + private $binaryString; + + /** + * @var resource + * @ORM\Column(type="binary") + */ + private $binaryResource; +} diff --git a/tests/Rules/Doctrine/ORM/data/bug-677.php b/tests/Rules/Doctrine/ORM/data/bug-677.php new file mode 100644 index 00000000..f84d1349 --- /dev/null +++ b/tests/Rules/Doctrine/ORM/data/bug-677.php @@ -0,0 +1,33 @@ += 8.1 + +namespace PHPStan\Rules\Doctrine\ORM\Bug677; + +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class MyBrokenEntity +{ + public const LOGIN_METHOD_BASIC_AUTH = 'BasicAuth'; + public const LOGIN_METHOD_SSO = 'SSO'; + public const LOGIN_METHOD_SAML = 'SAML'; + + public const LOGIN_METHODS = [ + self::LOGIN_METHOD_BASIC_AUTH, + self::LOGIN_METHOD_SSO, + self::LOGIN_METHOD_SAML, + ]; + + /** + * @var int|null + */ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private $id; + + /** + * @var self::LOGIN_METHOD_* + */ + #[ORM\Column(name: 'login_method', type: 'enum', options: ['default' => self::LOGIN_METHOD_BASIC_AUTH, 'values' => self::LOGIN_METHODS])] + private string $loginMethod = self::LOGIN_METHOD_BASIC_AUTH; +} diff --git a/tests/Rules/Doctrine/ORM/data/bug-679.php b/tests/Rules/Doctrine/ORM/data/bug-679.php new file mode 100644 index 00000000..f3f4b23c --- /dev/null +++ b/tests/Rules/Doctrine/ORM/data/bug-679.php @@ -0,0 +1,27 @@ += 8.1 + +namespace PHPStan\Rules\Doctrine\ORM\Bug679; + +use Doctrine\ORM\Mapping as ORM; + +enum FooEnum: string { + + case ONE = 'one'; + case TWO = 'two'; + +} + +#[ORM\Entity] +class MyBrokenEntity +{ + /** + * @var int|null + */ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private $id; + + #[ORM\Column(type: "enum", enumType: FooEnum::class)] + public FooEnum $type1; +} diff --git a/tests/Rules/Doctrine/ORM/data/bug-single-enum.php b/tests/Rules/Doctrine/ORM/data/bug-single-enum.php new file mode 100644 index 00000000..564e3dcb --- /dev/null +++ b/tests/Rules/Doctrine/ORM/data/bug-single-enum.php @@ -0,0 +1,29 @@ += 8.1 + +namespace PHPStan\Rules\Doctrine\ORM\BugSingleEnum; + +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class MyBrokenEntity +{ + public const LOGIN_METHOD_BASIC_AUTH = 'BasicAuth'; + + public const LOGIN_METHODS = [ + self::LOGIN_METHOD_BASIC_AUTH, + ]; + + /** + * @var int|null + */ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private $id; + + /** + * @var self::LOGIN_METHOD_* + */ + #[ORM\Column(name: 'login_method', type: 'enum', options: ['default' => self::LOGIN_METHOD_BASIC_AUTH, 'values' => self::LOGIN_METHODS])] + private string $loginMethod = self::LOGIN_METHOD_BASIC_AUTH; +} diff --git a/tests/Rules/Doctrine/ORM/data/query-builder-dql.php b/tests/Rules/Doctrine/ORM/data/query-builder-dql.php index 49aa95f2..e221f3ed 100644 --- a/tests/Rules/Doctrine/ORM/data/query-builder-dql.php +++ b/tests/Rules/Doctrine/ORM/data/query-builder-dql.php @@ -291,6 +291,19 @@ public function qbExprMethod(): void $queryBuilder->getQuery(); } + public function bug602(array $objectConditions, bool $rand): void + { + $orParts = ['e.title LIKE :termLike']; + if ($rand) { + $orParts[] = 'p.version = :term'; + } + $queryBuilder = $this->entityManager->createQueryBuilder(); + $queryBuilder->select('e') + ->from(MyEntity::class, 'e') + ->andWhere($queryBuilder->expr()->orX(...$orParts)) + ->setParameter('termLike', 'someTerm'); + } + } class CustomExpr extends \Doctrine\ORM\Query\Expr diff --git a/tests/Rules/Doctrine/ORM/entity-manager.php b/tests/Rules/Doctrine/ORM/entity-manager.php index 9181f8b8..500a2029 100644 --- a/tests/Rules/Doctrine/ORM/entity-manager.php +++ b/tests/Rules/Doctrine/ORM/entity-manager.php @@ -19,13 +19,13 @@ $metadataDriver = new MappingDriverChain(); $metadataDriver->addDriver(new AnnotationDriver( new AnnotationReader(), - [__DIR__ . '/data'] + [__DIR__ . '/data'], ), 'PHPStan\\Rules\\Doctrine\\ORM\\'); if (PHP_VERSION_ID >= 80100) { $metadataDriver->addDriver( new AttributeDriver([__DIR__ . '/data-attributes']), - 'PHPStan\\Rules\\Doctrine\\ORMAttributes\\' + 'PHPStan\\Rules\\Doctrine\\ORMAttributes\\', ); } @@ -33,7 +33,7 @@ Type::overrideType( 'date', - DateTimeImmutableType::class + DateTimeImmutableType::class, ); return new EntityManager( @@ -41,5 +41,5 @@ 'driver' => 'pdo_sqlite', 'memory' => true, ]), - $config + $config, ); diff --git a/tests/Rules/Doctrine/ORM/slow.neon b/tests/Rules/Doctrine/ORM/slow.neon deleted file mode 100644 index bfda5b8a..00000000 --- a/tests/Rules/Doctrine/ORM/slow.neon +++ /dev/null @@ -1,3 +0,0 @@ -parameters: - doctrine: - queryBuilderFastAlgorithm: false diff --git a/tests/Rules/Properties/MissingGedmoByPhpDocPropertyAssignRuleTest.php b/tests/Rules/Properties/MissingGedmoByPhpDocPropertyAssignRuleTest.php index 683ddff3..7b66451e 100644 --- a/tests/Rules/Properties/MissingGedmoByPhpDocPropertyAssignRuleTest.php +++ b/tests/Rules/Properties/MissingGedmoByPhpDocPropertyAssignRuleTest.php @@ -7,7 +7,6 @@ use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; use PHPStan\Type\Doctrine\ObjectMetadataResolver; -use const PHP_VERSION_ID; /** * @extends RuleTestCase @@ -34,10 +33,6 @@ public static function getAdditionalConfigFiles(): array public function testRule(): void { - if (PHP_VERSION_ID < 70400) { - self::markTestSkipped('Test requires PHP 7.4.'); - } - $this->analyse([__DIR__ . '/data/gedmo-property-assign-phpdoc.php'], [ // No errors expected ]); diff --git a/tests/Rules/Properties/MissingReadOnlyByPhpDocPropertyAssignRuleTest.php b/tests/Rules/Properties/MissingReadOnlyByPhpDocPropertyAssignRuleTest.php index 2f42a9a4..8a72494f 100644 --- a/tests/Rules/Properties/MissingReadOnlyByPhpDocPropertyAssignRuleTest.php +++ b/tests/Rules/Properties/MissingReadOnlyByPhpDocPropertyAssignRuleTest.php @@ -4,7 +4,6 @@ use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; -use const PHP_VERSION_ID; /** * @extends RuleTestCase @@ -24,10 +23,6 @@ public static function getAdditionalConfigFiles(): array public function testRule(): void { - if (PHP_VERSION_ID < 70400) { - self::markTestSkipped('Test requires PHP 7.4.'); - } - $this->analyse([__DIR__ . '/data/missing-readonly-property-assign-phpdoc.php'], [ [ 'Class MissingReadOnlyPropertyAssignPhpDoc\EntityWithAGeneratedId has an uninitialized @readonly property $unassigned. Assign it in the constructor.', diff --git a/tests/Rules/Properties/entity-manager.php b/tests/Rules/Properties/entity-manager.php index 99f7a07e..f36730f8 100644 --- a/tests/Rules/Properties/entity-manager.php +++ b/tests/Rules/Properties/entity-manager.php @@ -17,13 +17,13 @@ $metadataDriver = new MappingDriverChain(); $metadataDriver->addDriver(new AnnotationDriver( new AnnotationReader(), - [__DIR__ . '/data'] + [__DIR__ . '/data'], ), 'PHPStan\\Rules\\Doctrine\\ORM\\'); if (PHP_VERSION_ID >= 80100) { $metadataDriver->addDriver( new AttributeDriver([__DIR__ . '/data']), - 'PHPStan\\Rules\\Doctrine\\ORMAttributes\\' + 'PHPStan\\Rules\\Doctrine\\ORMAttributes\\', ); } @@ -34,5 +34,5 @@ 'driver' => 'pdo_sqlite', 'memory' => true, ]), - $config + $config, ); diff --git a/tests/Type/Doctrine/DBAL/MysqliResultRowCountReturnTypeTest.php b/tests/Type/Doctrine/DBAL/MysqliResultRowCountReturnTypeTest.php new file mode 100644 index 00000000..89ac5eb6 --- /dev/null +++ b/tests/Type/Doctrine/DBAL/MysqliResultRowCountReturnTypeTest.php @@ -0,0 +1,42 @@ + */ + public function dataFileAsserts(): iterable + { + $versionParser = new VersionParser(); + if (InstalledVersions::satisfies($versionParser, 'doctrine/dbal', '>=4.0')) { + yield from $this->gatherAssertTypes(__DIR__ . '/data/mysqli-result-row-count.php'); + } else { + yield from $this->gatherAssertTypes(__DIR__ . '/data/mysqli-result-row-count-dbal-3.php'); + } + } + + /** + * @dataProvider dataFileAsserts + * @param mixed ...$args + */ + public function testFileAsserts( + string $assertType, + string $file, + ...$args + ): void + { + $this->assertFileAsserts($assertType, $file, ...$args); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/mysqli.neon']; + } + +} diff --git a/tests/Type/Doctrine/DBAL/PDOResultRowCountReturnTypeTest.php b/tests/Type/Doctrine/DBAL/PDOResultRowCountReturnTypeTest.php new file mode 100644 index 00000000..0b6aa6bc --- /dev/null +++ b/tests/Type/Doctrine/DBAL/PDOResultRowCountReturnTypeTest.php @@ -0,0 +1,35 @@ + */ + public function dataFileAsserts(): iterable + { + yield from $this->gatherAssertTypes(__DIR__ . '/data/pdo-result-row-count.php'); + } + + /** + * @dataProvider dataFileAsserts + * @param mixed ...$args + */ + public function testFileAsserts( + string $assertType, + string $file, + ...$args + ): void + { + $this->assertFileAsserts($assertType, $file, ...$args); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/pdo.neon']; + } + +} diff --git a/tests/Type/Doctrine/DBAL/data/mysqli-result-row-count-dbal-3.php b/tests/Type/Doctrine/DBAL/data/mysqli-result-row-count-dbal-3.php new file mode 100644 index 00000000..8226f6e4 --- /dev/null +++ b/tests/Type/Doctrine/DBAL/data/mysqli-result-row-count-dbal-3.php @@ -0,0 +1,15 @@ +rowCount()); +}; + +function (DriverResult $r): void { + assertType('int', $r->rowCount()); +}; diff --git a/tests/Type/Doctrine/DBAL/data/mysqli-result-row-count.php b/tests/Type/Doctrine/DBAL/data/mysqli-result-row-count.php new file mode 100644 index 00000000..84f69543 --- /dev/null +++ b/tests/Type/Doctrine/DBAL/data/mysqli-result-row-count.php @@ -0,0 +1,15 @@ +rowCount()); +}; + +function (DriverResult $r): void { + assertType('int|numeric-string', $r->rowCount()); +}; diff --git a/tests/Type/Doctrine/DBAL/data/pdo-result-row-count.php b/tests/Type/Doctrine/DBAL/data/pdo-result-row-count.php new file mode 100644 index 00000000..ec73a00c --- /dev/null +++ b/tests/Type/Doctrine/DBAL/data/pdo-result-row-count.php @@ -0,0 +1,15 @@ +rowCount()); +}; + +function (DriverResult $r): void { + assertType('int', $r->rowCount()); +}; diff --git a/tests/Type/Doctrine/DBAL/mysqli.neon b/tests/Type/Doctrine/DBAL/mysqli.neon new file mode 100644 index 00000000..e287719f --- /dev/null +++ b/tests/Type/Doctrine/DBAL/mysqli.neon @@ -0,0 +1,6 @@ +includes: + - ../../../../extension.neon + +parameters: + doctrine: + objectManagerLoader: mysqli.php diff --git a/tests/Type/Doctrine/DBAL/mysqli.php b/tests/Type/Doctrine/DBAL/mysqli.php new file mode 100644 index 00000000..ce3859e5 --- /dev/null +++ b/tests/Type/Doctrine/DBAL/mysqli.php @@ -0,0 +1,25 @@ +setProxyDir(__DIR__); +$config->setProxyNamespace('App\GeneratedProxy'); +$config->setMetadataCache(new ArrayCachePool()); +$config->setMetadataDriverImpl(new AnnotationDriver( + new AnnotationReader(), + [__DIR__ . '/data'], +)); + +return new EntityManager( + DriverManager::getConnection([ + 'driver' => 'mysqli', + 'memory' => true, + ]), + $config, +); diff --git a/tests/Type/Doctrine/DBAL/pdo.neon b/tests/Type/Doctrine/DBAL/pdo.neon new file mode 100644 index 00000000..ee4897c8 --- /dev/null +++ b/tests/Type/Doctrine/DBAL/pdo.neon @@ -0,0 +1,6 @@ +includes: + - ../../../../extension.neon + +parameters: + doctrine: + objectManagerLoader: pdo.php diff --git a/tests/Type/Doctrine/DBAL/pdo.php b/tests/Type/Doctrine/DBAL/pdo.php new file mode 100644 index 00000000..7b6b0da3 --- /dev/null +++ b/tests/Type/Doctrine/DBAL/pdo.php @@ -0,0 +1,25 @@ +setProxyDir(__DIR__); +$config->setProxyNamespace('App\GeneratedProxy'); +$config->setMetadataCache(new ArrayCachePool()); +$config->setMetadataDriverImpl(new AnnotationDriver( + new AnnotationReader(), + [__DIR__ . '/data'], +)); + +return new EntityManager( + DriverManager::getConnection([ + 'driver' => 'pdo_pgsql', + 'memory' => true, + ]), + $config, +); diff --git a/tests/Type/Doctrine/DoctrineSelectableDynamicReturnTypeExtensionTest.php b/tests/Type/Doctrine/DoctrineSelectableDynamicReturnTypeExtensionTest.php index a34c7b22..01b66d7b 100644 --- a/tests/Type/Doctrine/DoctrineSelectableDynamicReturnTypeExtensionTest.php +++ b/tests/Type/Doctrine/DoctrineSelectableDynamicReturnTypeExtensionTest.php @@ -15,8 +15,7 @@ final class DoctrineSelectableDynamicReturnTypeExtensionTest extends TestCase { - /** @var DoctrineSelectableDynamicReturnTypeExtension */ - private $extension; + private DoctrineSelectableDynamicReturnTypeExtension $extension; protected function setUp(): void { @@ -52,10 +51,8 @@ public function testGetTypeFromMethodCall(): void $scope = $this->createMock(Scope::class); $scope->method('getType')->will( self::returnCallback( - static function (): Type { - return new ObjectType(Collection::class); - } - ) + static fn (): Type => new ObjectType(Collection::class), + ), ); $var = $this->createMock(Expr::class); diff --git a/tests/Type/Doctrine/NewExprTest.php b/tests/Type/Doctrine/NewExprTest.php index ee3ff1d2..e1bb9dde 100644 --- a/tests/Type/Doctrine/NewExprTest.php +++ b/tests/Type/Doctrine/NewExprTest.php @@ -7,9 +7,6 @@ class NewExprTest extends TypeInferenceTestCase { - /** - * @return iterable - */ public function dataFileAsserts(): iterable { yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-191.php'); diff --git a/tests/Type/Doctrine/Query/QueryResultTypeWalkerHydrationModeTest.php b/tests/Type/Doctrine/Query/QueryResultTypeWalkerHydrationModeTest.php new file mode 100644 index 00000000..4c86b2b3 --- /dev/null +++ b/tests/Type/Doctrine/Query/QueryResultTypeWalkerHydrationModeTest.php @@ -0,0 +1,421 @@ +getMetadataFactory()->getAllMetadata(); + $schemaTool->dropSchema($classes); + $schemaTool->createSchema($classes); + + $simple = new Simple(); + $simple->id = '1'; + $simple->intColumn = 1; + $simple->floatColumn = 0.1; + $simple->decimalColumn = '1.1'; + $simple->stringColumn = 'foobar'; + $simple->stringNullColumn = null; + + $entityManager->persist($simple); + $entityManager->flush(); + + $query = $entityManager->createQuery($dql); + + $typeBuilder = new QueryResultTypeBuilder(); + + QueryResultTypeWalker::walk( + $query, + $typeBuilder, + self::getContainer()->getByType(DescriptorRegistry::class), + self::getContainer()->getByType(PhpVersion::class), + self::getContainer()->getByType(DriverDetector::class), + ); + + $resolver = self::getContainer()->getByType(HydrationModeReturnTypeResolver::class); + + $type = $resolver->getMethodReturnTypeForHydrationMode( + $methodName, + new ConstantIntegerType($this->getRealHydrationMode($methodName, $hydrationMode)), + $typeBuilder->getIndexType(), + $typeBuilder->getResultType(), + $entityManager, + ) ?? new MixedType(); + + self::assertSame( + $expectedType->describe(VerbosityLevel::precise()), + $type->describe(VerbosityLevel::precise()), + ); + + $query = $entityManager->createQuery($dql); + $result = $this->getQueryResult($query, $methodName, $hydrationMode); + + $resultType = ConstantTypeHelper::getTypeFromValue($result); + self::assertTrue( + $type->accepts($resultType, true)->yes(), + sprintf( + "The inferred type\n%s\nshould accept actual type\n%s", + $type->describe(VerbosityLevel::precise()), + $resultType->describe(VerbosityLevel::precise()), + ), + ); + } + + /** + * @return iterable + */ + public static function getTestData(): iterable + { + yield 'getResult(object), full entity' => [ + self::list(new ObjectType(Simple::class)), + ' + SELECT s + FROM QueryResult\Entities\Simple s + ', + 'getResult', + Query::HYDRATE_OBJECT, + ]; + + yield 'getResult(simple_object), full entity' => [ + self::list(new ObjectType(Simple::class)), + ' + SELECT s + FROM QueryResult\Entities\Simple s + ', + 'getResult', + Query::HYDRATE_SIMPLEOBJECT, + ]; + + yield 'getResult(object), fields' => [ + self::list(self::constantArray([ + [new ConstantStringType('decimalColumn'), self::numericString(false, true)], + [new ConstantStringType('floatColumn'), new FloatType()], + ])), + ' + SELECT s.decimalColumn, s.floatColumn + FROM QueryResult\Entities\Simple s + ', + 'getResult', + Query::HYDRATE_OBJECT, + ]; + + yield 'getResult(object), expressions' => [ + self::list(self::constantArray([ + [new ConstantStringType('decimalColumn'), self::floatOrIntOrStringified()], + [new ConstantStringType('floatColumn'), self::floatOrStringified()], + ])), + ' + SELECT -s.decimalColumn as decimalColumn, -s.floatColumn as floatColumn + FROM QueryResult\Entities\Simple s + ', + 'getResult', + Query::HYDRATE_OBJECT, + ]; + + yield 'toIterable(object), full entity' => [ + new IterableType(new IntegerType(), new ObjectType(Simple::class)), + ' + SELECT s + FROM QueryResult\Entities\Simple s + ', + 'toIterable', + Query::HYDRATE_OBJECT, + ]; + + yield 'toIterable(object), fields' => [ + new IterableType(new IntegerType(), self::constantArray([ + [new ConstantStringType('decimalColumn'), self::numericString(false, true)], + [new ConstantStringType('floatColumn'), new FloatType()], + ])), + ' + SELECT s.decimalColumn, s.floatColumn + FROM QueryResult\Entities\Simple s + ', + 'toIterable', + Query::HYDRATE_OBJECT, + ]; + + yield 'toIterable(object), expressions' => [ + new IterableType(new IntegerType(), self::constantArray([ + [new ConstantStringType('decimalColumn'), self::floatOrIntOrStringified()], + [new ConstantStringType('floatColumn'), self::floatOrStringified()], + ])), + ' + SELECT -s.decimalColumn as decimalColumn, -s.floatColumn as floatColumn + FROM QueryResult\Entities\Simple s + ', + 'toIterable', + Query::HYDRATE_OBJECT, + ]; + + yield 'toIterable(simple_object), full entity' => [ + new IterableType(new IntegerType(), new ObjectType(Simple::class)), + ' + SELECT s + FROM QueryResult\Entities\Simple s + ', + 'toIterable', + Query::HYDRATE_SIMPLEOBJECT, + ]; + + yield 'getArrayResult(), full entity' => [ + new MixedType(), + ' + SELECT s + FROM QueryResult\Entities\Simple s + ', + 'getArrayResult', + ]; + + yield 'getResult(single_scalar), decimal field' => [ + new MixedType(), + ' + SELECT s.decimalColumn + FROM QueryResult\Entities\Simple s + ', + 'getResult', + Query::HYDRATE_SINGLE_SCALAR, + ]; + + yield 'getResult(scalar), full entity' => [ + new MixedType(), + ' + SELECT s + FROM QueryResult\Entities\Simple s + ', + 'getResult', + Query::HYDRATE_SCALAR, + ]; + + yield 'getResult(scalar), decimal field' => [ + new MixedType(), + ' + SELECT s.decimalColumn + FROM QueryResult\Entities\Simple s + ', + 'getResult', + Query::HYDRATE_SCALAR, + ]; + + yield 'getScalarResult, full entity' => [ + new MixedType(), + ' + SELECT s + FROM QueryResult\Entities\Simple s + ', + 'getScalarResult', + ]; + + yield 'getScalarResult(), decimal field' => [ + new MixedType(), + ' + SELECT s.decimalColumn + FROM QueryResult\Entities\Simple s + ', + 'getScalarResult', + ]; + + yield 'getScalarResult(), decimal expression' => [ + new MixedType(), + ' + SELECT -s.decimalColumn as col + FROM QueryResult\Entities\Simple s + ', + 'getScalarResult', + ]; + + yield 'getSingleScalarResult(), decimal field' => [ + new MixedType(), + ' + SELECT s.decimalColumn + FROM QueryResult\Entities\Simple s + ', + 'getSingleScalarResult', + ]; + } + + /** + * @param array $elements + */ + private static 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 static function list(Type $values): Type + { + return TypeCombinator::intersect(new ArrayType(new IntegerType(), $values), new AccessoryArrayListType()); + } + + private static function numericString(bool $lowercase = false, bool $uppercase = false): Type + { + $types = [ + new StringType(), + new AccessoryNumericStringType(), + ]; + if ($lowercase) { + $types[] = new AccessoryLowercaseStringType(); + } + if ($uppercase) { + $types[] = new AccessoryUppercaseStringType(); + } + + return new IntersectionType($types); + } + + /** + * @param Query $query + * @return mixed + */ + private function getQueryResult(Query $query, string $methodName, ?int $hydrationMode) + { + if ($methodName === 'getResult') { + if ($hydrationMode === null) { + throw new LogicException('Hydration mode must be set for getResult() method.'); + } + return $query->getResult($hydrationMode); // @phpstan-ignore-line dynamic arg + } + + if ($methodName === 'getArrayResult') { + if ($hydrationMode !== null) { + throw new LogicException('Hydration mode must NOT be set for getArrayResult() method.'); + } + return $query->getArrayResult(); + } + + if ($methodName === 'getScalarResult') { + if ($hydrationMode !== null) { + throw new LogicException('Hydration mode must NOT be set for getScalarResult() method.'); + } + return $query->getScalarResult(); + } + + if ($methodName === 'getSingleResult') { + if ($hydrationMode === null) { + throw new LogicException('Hydration mode must be set for getSingleResult() method.'); + } + + return $query->getSingleResult($hydrationMode); // @phpstan-ignore-line dynamic arg + } + + if ($methodName === 'getSingleScalarResult') { + if ($hydrationMode !== null) { + throw new LogicException('Hydration mode must NOT be set for getSingleScalarResult() method.'); + } + + return $query->getSingleScalarResult(); + } + + if ($methodName === 'toIterable') { + if ($hydrationMode === null) { + throw new LogicException('Hydration mode must be set for toIterable() method.'); + } + + return $query->toIterable([], $hydrationMode); // @phpstan-ignore-line dynamic arg + } + + throw new LogicException(sprintf('Unsupported method %s.', $methodName)); + } + + private function getRealHydrationMode(string $methodName, ?int $hydrationMode): int + { + if ($hydrationMode !== null) { + return $hydrationMode; + } + + if ($methodName === 'getArrayResult') { + return Query::HYDRATE_ARRAY; + } + + if ($methodName === 'getScalarResult') { + return Query::HYDRATE_SCALAR; + } + + if ($methodName === 'getSingleScalarResult') { + return Query::HYDRATE_SCALAR; + } + + throw new LogicException(sprintf('Using %s without hydration mode is not supported.', $methodName)); + } + + private static function stringifies(): bool + { + return PHP_VERSION_ID < 80100; + } + + private static function floatOrStringified(): Type + { + return self::stringifies() + ? self::numericString(false, true) + : new FloatType(); + } + + private static function floatOrIntOrStringified(): Type + { + return self::stringifies() + ? self::numericString(false, true) + : TypeCombinator::union(new FloatType(), new IntegerType()); + } + +} diff --git a/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php b/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php index ea1e0aea..cd3da8de 100644 --- a/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php +++ b/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php @@ -7,13 +7,20 @@ use DateTime; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\DBAL\Types\Type as DbalType; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\Column; -use Doctrine\ORM\Query\AST\TypedExpression; use Doctrine\ORM\Tools\SchemaTool; +use PHPStan\Doctrine\Driver\DriverDetector; +use PHPStan\Php\PhpVersion; use PHPStan\Testing\PHPStanTestCase; +use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\AccessoryLowercaseStringType; use PHPStan\Type\Accessory\AccessoryNumericStringType; +use PHPStan\Type\Accessory\AccessoryUppercaseStringType; +use PHPStan\Type\ArrayType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; +use PHPStan\Type\Constant\ConstantFloatType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\ConstantTypeHelper; @@ -38,10 +45,12 @@ use QueryResult\Entities\One; use QueryResult\Entities\OneId; use QueryResult\Entities\SingleTableChild; +use QueryResult\EntitiesDbal42\Dbal4Entity; use QueryResult\EntitiesEnum\EntityWithEnum; use QueryResult\EntitiesEnum\IntEnum; use QueryResult\EntitiesEnum\StringEnum; use Throwable; +use Type\Doctrine\data\QueryResult\CustomIntType; use function array_merge; use function array_shift; use function assert; @@ -56,11 +65,9 @@ final class QueryResultTypeWalkerTest extends PHPStanTestCase { - /** @var EntityManagerInterface */ - private static $em; + private static EntityManagerInterface $em; - /** @var DescriptorRegistry */ - private $descriptorRegistry; + private DescriptorRegistry $descriptorRegistry; public static function getAdditionalConfigFiles(): array { @@ -74,12 +81,17 @@ public static function setUpBeforeClass(): void $em = require __DIR__ . '/../data/QueryResult/entity-manager.php'; self::$em = $em; + if (!DbalType::hasType(CustomIntType::NAME)) { + DbalType::addType(CustomIntType::NAME, CustomIntType::class); + } + $schemaTool = new SchemaTool($em); $classes = $em->getMetadataFactory()->getAllMetadata(); $schemaTool->createSchema($classes); $dataOne = [ 'intColumn' => [1, 2], + 'floatColumn' => [0.1, 2.0], 'stringColumn' => ['A', 'B'], 'stringNullColumn' => ['A', null], ]; @@ -106,10 +118,11 @@ public static function setUpBeforeClass(): void $id = 1; foreach (self::combinations($dataOne) as $combination) { - [$intColumn, $stringColumn, $stringNullColumn] = $combination; + [$intColumn, $floatColumn, $stringColumn, $stringNullColumn] = $combination; $one = new One(); $one->id = (string) $id++; $one->intColumn = $intColumn; + $one->floatColumn = $floatColumn; $one->stringColumn = $stringColumn; $one->stringNullColumn = $stringNullColumn; $embedded = new Embedded(); @@ -172,9 +185,19 @@ public static function setUpBeforeClass(): void $entityWithEnum->stringEnumColumn = StringEnum::A; $entityWithEnum->intEnumColumn = IntEnum::A; $entityWithEnum->intEnumOnStringColumn = IntEnum::A; + $entityWithEnum->stringEnumListColumn = [StringEnum::A, StringEnum::B]; $em->persist($entityWithEnum); } + if (InstalledVersions::satisfies(new VersionParser(), 'doctrine/dbal', '>=4.2')) { + assert(class_exists(Dbal4Entity::class)); + + $dbal4Entity = new Dbal4Entity(); + $dbal4Entity->enum = 'a'; + $dbal4Entity->smallfloat = 1.1; + $em->persist($dbal4Entity); + } + $em->flush(); } @@ -189,7 +212,7 @@ public function setUp(): void } /** @dataProvider getTestData */ - public function test(Type $expectedType, string $dql, ?string $expectedExceptionMessage = null, ?string $expectedDeprecationMessage = null): void + public function test(Type $expectedType, string $dql, ?string $expectedExceptionMessage = null): void { $em = self::$em; @@ -200,18 +223,21 @@ public function test(Type $expectedType, string $dql, ?string $expectedException if ($expectedExceptionMessage !== null) { $this->expectException(Throwable::class); $this->expectExceptionMessage($expectedExceptionMessage); - } elseif ($expectedDeprecationMessage !== null) { - $this->expectDeprecation(); - $this->expectDeprecationMessage($expectedDeprecationMessage); } - QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry); + QueryResultTypeWalker::walk( + $query, + $typeBuilder, + $this->descriptorRegistry, + self::getContainer()->getByType(PhpVersion::class), + self::getContainer()->getByType(DriverDetector::class), + ); $type = $typeBuilder->getResultType(); self::assertSame( $expectedType->describe(VerbosityLevel::precise()), - $type->describe(VerbosityLevel::precise()) + $type->describe(VerbosityLevel::precise()), ); // Double-check our expectations @@ -228,8 +254,8 @@ public function test(Type $expectedType, string $dql, ?string $expectedException sprintf( "The inferred type\n%s\nshould accept actual type\n%s", $type->describe(VerbosityLevel::precise()), - $rowType->describe(VerbosityLevel::precise()) - ) + $rowType->describe(VerbosityLevel::precise()), + ), ); } } @@ -239,9 +265,6 @@ public function test(Type $expectedType, string $dql, ?string $expectedException */ public function getTestData(): iterable { - $ormVersion = InstalledVersions::getVersion('doctrine/orm'); - $hasOrm3 = $ormVersion !== null && strpos($ormVersion, '3.') === 0; - $dbalVersion = InstalledVersions::getVersion('doctrine/dbal'); $hasDbal4 = $dbalVersion !== null && strpos($dbalVersion, '4.') === 0; @@ -278,7 +301,7 @@ public function getTestData(): iterable yield 'arbitrary left join, selected' => [ TypeCombinator::union( new ObjectType(Many::class), - TypeCombinator::addNull(new ObjectType(One::class)) + TypeCombinator::addNull(new ObjectType(One::class)), ), ' SELECT m, o @@ -291,7 +314,7 @@ public function getTestData(): iterable yield 'arbitrary inner join, selected' => [ TypeCombinator::union( new ObjectType(Many::class), - new ObjectType(One::class) + new ObjectType(One::class), ), ' SELECT m, o @@ -308,7 +331,7 @@ public function getTestData(): iterable ]), $this->constantArray([ [new ConstantIntegerType(0), TypeCombinator::addNull(new ObjectType(One::class))], - ]) + ]), ), ' SELECT m AS many, o @@ -325,7 +348,7 @@ public function getTestData(): iterable ]), $this->constantArray([ [new ConstantStringType('one'), TypeCombinator::addNull(new ObjectType(One::class))], - ]) + ]), ), ' SELECT m AS many, o AS one @@ -342,7 +365,7 @@ public function getTestData(): iterable ]), $this->constantArray([ [new ConstantStringType('one'), new ObjectType(One::class)], - ]) + ]), ), ' SELECT m AS many, o AS one @@ -361,9 +384,9 @@ public function getTestData(): iterable ]), $this->constantArray([ [new ConstantIntegerType(0), new ObjectType(One::class)], - [new ConstantStringType('id'), $hasDbal4 ? new IntegerType() : $this->numericString()], + [new ConstantStringType('id'), $hasDbal4 ? new IntegerType() : $this->numericString(true, true)], [new ConstantStringType('intColumn'), new IntegerType()], - ]) + ]), ), ' SELECT m, o, m.id, o.intColumn @@ -383,9 +406,9 @@ public function getTestData(): iterable ]), $this->constantArray([ [new ConstantIntegerType(0), new ObjectType(Many::class)], - [new ConstantStringType('id'), $hasDbal4 ? new IntegerType() : $this->numericString()], + [new ConstantStringType('id'), $hasDbal4 ? new IntegerType() : $this->numericString(true, true)], [new ConstantStringType('intColumn'), new IntegerType()], - ]) + ]), ), ' SELECT o, m2, m, m.id, o.intColumn @@ -404,9 +427,9 @@ public function getTestData(): iterable ]), $this->constantArray([ [new ConstantStringType('one'), new ObjectType(One::class)], - [new ConstantStringType('id'), $hasDbal4 ? new IntegerType() : $this->numericString()], + [new ConstantStringType('id'), $hasDbal4 ? new IntegerType() : $this->numericString(true, true)], [new ConstantStringType('intColumn'), new IntegerType()], - ]) + ]), ), ' SELECT m AS many, o AS one, m.id, o.intColumn @@ -514,7 +537,7 @@ public function getTestData(): iterable yield 'just root entity and scalars' => [ $this->constantArray([ [new ConstantIntegerType(0), new ObjectType(One::class)], - [new ConstantStringType('id'), $hasDbal4 ? new IntegerType() : $this->numericString()], + [new ConstantStringType('id'), $hasDbal4 ? new IntegerType() : $this->numericString(true, true)], ]), ' SELECT o, o.id @@ -522,59 +545,6 @@ public function getTestData(): iterable ', ]; - if (property_exists(Column::class, 'enumType') && PHP_VERSION_ID >= 80100) { - assert(class_exists(StringEnum::class)); - assert(class_exists(IntEnum::class)); - - // https://github.com/doctrine/orm/issues/9622 - if (!$this->isDoctrine211()) { - yield 'enum' => [ - $this->constantArray([ - [new ConstantStringType('stringEnumColumn'), new ObjectType(StringEnum::class)], - [new ConstantStringType('intEnumColumn'), new ObjectType(IntEnum::class)], - ]), - ' - SELECT e.stringEnumColumn, e.intEnumColumn - FROM QueryResult\EntitiesEnum\EntityWithEnum e - ', - ]; - } - - yield 'enum in expression' => [ - $this->constantArray([ - [ - new ConstantIntegerType(1), - TypeCombinator::union( - new ConstantStringType('a'), - new ConstantStringType('b') - ), - ], - [ - new ConstantIntegerType(2), - TypeCombinator::union( - new ConstantIntegerType(1), - new ConstantIntegerType(2), - new ConstantStringType('1'), - new ConstantStringType('2') - ), - ], - [ - new ConstantIntegerType(3), - TypeCombinator::union( - new ConstantStringType('1'), - new ConstantStringType('2') - ), - ], - ]), - ' - SELECT COALESCE(e.stringEnumColumn, e.stringEnumColumn), - COALESCE(e.intEnumColumn, e.intEnumColumn), - COALESCE(e.intEnumOnStringColumn, e.intEnumOnStringColumn) - FROM QueryResult\EntitiesEnum\EntityWithEnum e - ', - ]; - } - yield 'hidden' => [ $this->constantArray([ [new ConstantStringType('intColumn'), new IntegerType()], @@ -603,7 +573,7 @@ public function getTestData(): iterable ], [ new ConstantIntegerType(1), - TypeCombinator::addNull($this->intStringified()), + TypeCombinator::addNull($this->intOrStringified()), ], [ new ConstantIntegerType(2), @@ -611,27 +581,23 @@ public function getTestData(): iterable ], [ new ConstantIntegerType(3), - $this->hasTypedExpressions() - ? $this->uint() - : $this->uintStringified(), + $this->uint(), ], [ new ConstantIntegerType(4), - TypeCombinator::addNull($this->intStringified()), + TypeCombinator::addNull($this->intOrStringified()), ], [ new ConstantIntegerType(5), - $this->hasTypedExpressions() - ? $this->uint() - : $this->uintStringified(), + $this->uint(), ], [ new ConstantIntegerType(6), - TypeCombinator::addNull($this->intStringified()), + TypeCombinator::addNull($this->intOrStringified()), ], [ new ConstantIntegerType(7), - $this->intStringified(), + $this->intOrStringified(), ], ]), ' @@ -645,6 +611,37 @@ public function getTestData(): iterable ', ]; + yield 'aggregate deeper in AST' => [ + $this->constantArray([ + [ + new ConstantStringType('many'), + TypeCombinator::addNull(new ObjectType(Many::class)), + ], + [ + new ConstantStringType('max'), + $this->intOrStringified(), + ], + ]), + ' + SELECT m AS many, + COALESCE(MAX(m.intColumn), 0) as max + FROM QueryResult\Entities\Many m + ', + ]; + + yield 'aggregate lowercase' => [ + $this->constantArray([ + [ + new ConstantStringType('foo'), + TypeCombinator::addNull($this->floatOrStringified()), + ], + ]), + ' + SELECT avg(m.intColumn) as foo + FROM QueryResult\Entities\Many m + ', + ]; + yield 'aggregate with group by' => [ $this->constantArray([ [ @@ -653,21 +650,19 @@ public function getTestData(): iterable ], [ new ConstantStringType('max'), - TypeCombinator::addNull($this->intStringified()), + $this->intOrStringified(), ], [ new ConstantStringType('arithmetic'), - $this->intStringified(), + $this->intOrStringified(), ], [ new ConstantStringType('coalesce'), - $this->intStringified(), + $this->intOrStringified(), ], [ new ConstantStringType('count'), - $this->hasTypedExpressions() - ? $this->uint() - : $this->uintStringified(), + $this->uint(), ], ]), ' @@ -686,62 +681,52 @@ public function getTestData(): iterable [ new ConstantIntegerType(1), TypeCombinator::union( - new ConstantStringType('1'), - new ConstantIntegerType(1), - new NullType() + $this->intOrStringified(), + new NullType(), ), ], [ new ConstantIntegerType(2), TypeCombinator::union( - new ConstantStringType('0'), - new ConstantIntegerType(0), - new ConstantStringType('1'), - new ConstantIntegerType(1), - new NullType() + $this->intOrStringified(), + new NullType(), ), ], [ new ConstantIntegerType(3), TypeCombinator::union( - new ConstantStringType('1'), - new ConstantIntegerType(1), - new NullType() + $this->intOrStringified(), + new NullType(), ), ], [ new ConstantIntegerType(4), TypeCombinator::union( - new ConstantStringType('0'), - new ConstantIntegerType(0), - new ConstantStringType('1'), - new ConstantIntegerType(1), - new NullType() + $this->intOrStringified(), + new NullType(), ), ], [ new ConstantIntegerType(5), TypeCombinator::union( - $this->intStringified(), - new FloatType(), - new NullType() + $this->floatOrStringified(), + new NullType(), ), ], [ new ConstantIntegerType(6), TypeCombinator::union( - $this->intStringified(), - new FloatType(), - new NullType() + $this->floatOrStringified(), + new NullType(), ), ], [ new ConstantIntegerType(7), - TypeCombinator::addNull($this->intStringified()), + TypeCombinator::addNull($this->intOrStringified()), ], [ new ConstantIntegerType(8), - TypeCombinator::addNull($this->intStringified()), + TypeCombinator::addNull($this->intOrStringified()), ], ]), ' @@ -761,10 +746,7 @@ public function getTestData(): iterable $this->constantArray([ [ new ConstantIntegerType(1), - TypeCombinator::union( - new ConstantStringType('1'), - new ConstantIntegerType(1) - ), + $this->stringifies() ? new ConstantStringType('1') : new ConstantIntegerType(1), ], [new ConstantIntegerType(2), new ConstantStringType('hello')], ]), @@ -779,9 +761,8 @@ public function getTestData(): iterable [ new ConstantIntegerType(1), TypeCombinator::union( - new ConstantIntegerType(1), - new ConstantStringType('1'), - new NullType() + $this->stringifies() ? new ConstantStringType('1') : new ConstantIntegerType(1), + new NullType(), ), ], ]), @@ -797,25 +778,35 @@ public function getTestData(): iterable new ConstantIntegerType(1), TypeCombinator::union( new StringType(), - new IntegerType() + $this->intOrStringified(), ), ], [ new ConstantIntegerType(2), TypeCombinator::union( new StringType(), - new NullType() + new NullType(), ), ], [ new ConstantIntegerType(3), - $this->intStringified(), + $this->intOrStringified(), + ], + [ + new ConstantIntegerType(4), + $this->stringifies() + ? $this->numericString(false, true) + : TypeCombinator::union( + new IntegerType(), + new FloatType(), + ), ], ]), ' SELECT COALESCE(m.stringNullColumn, m.intColumn, false), COALESCE(m.stringNullColumn, m.stringNullColumn), - COALESCE(NULLIF(m.intColumn, 1), 0) + COALESCE(NULLIF(m.intColumn, 1), 0), + COALESCE(1, 1.1) FROM QueryResult\Entities\Many m ', ]; @@ -826,7 +817,7 @@ public function getTestData(): iterable new ConstantIntegerType(1), TypeCombinator::union( new StringType(), - new ConstantIntegerType(0) + $this->stringifies() ? new ConstantStringType('0') : new ConstantIntegerType(0), ), ], ]), @@ -846,7 +837,7 @@ public function getTestData(): iterable new ConstantIntegerType(1), TypeCombinator::union( new StringType(), - new ConstantIntegerType(0) + $this->stringifies() ? new ConstantStringType('0') : new ConstantIntegerType(0), ), ], ]), @@ -865,10 +856,8 @@ public function getTestData(): iterable [ new ConstantIntegerType(1), TypeCombinator::union( - new ConstantIntegerType(0), - new ConstantIntegerType(1), - new ConstantStringType('0'), - new ConstantStringType('1') + $this->stringifies() ? new ConstantStringType('0') : new ConstantIntegerType(0), + $this->stringifies() ? new ConstantStringType('1') : new ConstantIntegerType(1), ), ], ]), @@ -886,10 +875,8 @@ public function getTestData(): iterable [ new ConstantIntegerType(1), TypeCombinator::union( - new ConstantIntegerType(0), - new ConstantIntegerType(1), - new ConstantStringType('0'), - new ConstantStringType('1') + $this->stringifies() ? new ConstantStringType('0') : new ConstantIntegerType(0), + $this->stringifies() ? new ConstantStringType('1') : new ConstantIntegerType(1), ), ], ]), @@ -906,31 +893,19 @@ public function getTestData(): iterable $this->constantArray([ [ new ConstantIntegerType(1), - TypeCombinator::union( - new ConstantIntegerType(1), - new ConstantStringType('1') - ), + $this->stringifies() ? new ConstantStringType('1') : new ConstantIntegerType(1), ], [ new ConstantIntegerType(2), - TypeCombinator::union( - new ConstantIntegerType(0), - new ConstantStringType('0') - ), + $this->stringifies() ? new ConstantStringType('0') : new ConstantIntegerType(0), ], [ new ConstantIntegerType(3), - TypeCombinator::union( - new ConstantIntegerType(1), - new ConstantStringType('1') - ), + $this->stringifies() ? new ConstantStringType('1') : new ConstantIntegerType(1), ], [ new ConstantIntegerType(4), - TypeCombinator::union( - new ConstantIntegerType(0), - new ConstantStringType('0') - ), + $this->stringifies() ? new ConstantStringType('0') : new ConstantIntegerType(0), ], ]), ' @@ -1102,10 +1077,7 @@ public function getTestData(): iterable ], [ new ConstantIntegerType(2), - TypeCombinator::union( - new ConstantIntegerType(1), - new ConstantStringType('1') - ), + $this->intOrStringified(), ], [ new ConstantStringType('intColumn'), @@ -1119,7 +1091,7 @@ public function getTestData(): iterable new ConstantIntegerType(1), new ObjectType(OneId::class), ], - ]) + ]), ), ' SELECT NEW QueryResult\Entities\ManyId(m.id), @@ -1149,7 +1121,7 @@ public function getTestData(): iterable yield 'new arguments affect scalar counter' => [ $this->constantArray([ - [new ConstantIntegerType(5), TypeCombinator::addNull($this->intStringified())], + [new ConstantIntegerType(5), TypeCombinator::addNull($this->intOrStringified())], [new ConstantIntegerType(0), new ObjectType(ManyId::class)], [new ConstantIntegerType(1), new ObjectType(OneId::class)], ]), @@ -1164,13 +1136,13 @@ public function getTestData(): iterable yield 'arithmetic' => [ $this->constantArray([ [new ConstantStringType('intColumn'), new IntegerType()], - [new ConstantIntegerType(1), $this->intStringified()], - [new ConstantIntegerType(2), $this->intStringified()], - [new ConstantIntegerType(3), TypeCombinator::addNull($this->intStringified())], - [new ConstantIntegerType(4), $this->intStringified()], - [new ConstantIntegerType(5), $this->intStringified()], - [new ConstantIntegerType(6), $this->numericStringified()], - [new ConstantIntegerType(7), $this->numericStringified()], + [new ConstantIntegerType(1), $this->stringifies() ? new ConstantStringType('1') : new ConstantIntegerType(1)], + [new ConstantIntegerType(2), $this->intOrStringified()], + [new ConstantIntegerType(3), TypeCombinator::addNull($this->intOrStringified())], + [new ConstantIntegerType(4), $this->intOrStringified()], + [new ConstantIntegerType(5), $this->intOrStringified()], + [new ConstantIntegerType(6), new MixedType()], + [new ConstantIntegerType(7), new MixedType()], ]), ' SELECT m.intColumn, @@ -1187,10 +1159,10 @@ public function getTestData(): iterable yield 'abs function' => [ $this->constantArray([ - [new ConstantIntegerType(1), $this->unumericStringified()], - [new ConstantIntegerType(2), TypeCombinator::addNull($this->unumericStringified())], - [new ConstantIntegerType(3), $this->unumericStringified()], - [new ConstantIntegerType(4), TypeCombinator::union($this->unumericStringified())], + [new ConstantIntegerType(1), $this->uintOrStringified()], + [new ConstantIntegerType(2), TypeCombinator::addNull($this->uintOrStringified())], + [new ConstantIntegerType(3), $this->uintOrStringified()], + [new ConstantIntegerType(4), new MixedType()], ]), ' SELECT ABS(m.intColumn), @@ -1201,11 +1173,21 @@ public function getTestData(): iterable ', ]; + yield 'abs function with mixed' => [ + $this->constantArray([ + [new ConstantIntegerType(1), new MixedType()], + ]), + ' + SELECT ABS(o.mixedColumn) + FROM QueryResult\Entities\One o + ', + ]; + yield 'bit_and function' => [ $this->constantArray([ - [new ConstantIntegerType(1), $this->uintStringified()], - [new ConstantIntegerType(2), TypeCombinator::addNull($this->uintStringified())], - [new ConstantIntegerType(3), $this->uintStringified()], + [new ConstantIntegerType(1), $this->uintOrStringified()], + [new ConstantIntegerType(2), TypeCombinator::addNull($this->uintOrStringified())], + [new ConstantIntegerType(3), $this->uintOrStringified()], ]), ' SELECT BIT_AND(m.intColumn, 1), @@ -1217,9 +1199,9 @@ public function getTestData(): iterable yield 'bit_or function' => [ $this->constantArray([ - [new ConstantIntegerType(1), $this->uintStringified()], - [new ConstantIntegerType(2), TypeCombinator::addNull($this->uintStringified())], - [new ConstantIntegerType(3), $this->uintStringified()], + [new ConstantIntegerType(1), $this->uintOrStringified()], + [new ConstantIntegerType(2), TypeCombinator::addNull($this->uintOrStringified())], + [new ConstantIntegerType(3), $this->uintOrStringified()], ]), ' SELECT BIT_OR(m.intColumn, 1), @@ -1259,46 +1241,12 @@ public function getTestData(): iterable ', ]; - if (!$hasOrm3) { - yield 'date_add function' => [ - $this->constantArray([ - [new ConstantIntegerType(1), new StringType()], - [new ConstantIntegerType(2), TypeCombinator::addNull(new StringType())], - [new ConstantIntegerType(3), TypeCombinator::addNull(new StringType())], - [new ConstantIntegerType(4), new StringType()], - ]), - ' - SELECT DATE_ADD(m.datetimeColumn, m.intColumn, \'day\'), - DATE_ADD(m.stringNullColumn, m.intColumn, \'day\'), - DATE_ADD(m.datetimeColumn, NULLIF(m.intColumn, 1), \'day\'), - DATE_ADD(\'2020-01-01\', 7, \'day\') - FROM QueryResult\Entities\Many m - ', - ]; - - yield 'date_sub function' => [ - $this->constantArray([ - [new ConstantIntegerType(1), new StringType()], - [new ConstantIntegerType(2), TypeCombinator::addNull(new StringType())], - [new ConstantIntegerType(3), TypeCombinator::addNull(new StringType())], - [new ConstantIntegerType(4), new StringType()], - ]), - ' - SELECT DATE_SUB(m.datetimeColumn, m.intColumn, \'day\'), - DATE_SUB(m.stringNullColumn, m.intColumn, \'day\'), - DATE_SUB(m.datetimeColumn, NULLIF(m.intColumn, 1), \'day\'), - DATE_SUB(\'2020-01-01\', 7, \'day\') - FROM QueryResult\Entities\Many m - ', - ]; - } - yield 'date_diff function' => [ $this->constantArray([ - [new ConstantIntegerType(1), $this->numericStringified()], - [new ConstantIntegerType(2), TypeCombinator::addNull($this->numericStringified())], - [new ConstantIntegerType(3), TypeCombinator::addNull($this->numericStringified())], - [new ConstantIntegerType(4), $this->numericStringified()], + [new ConstantIntegerType(1), $this->floatOrStringified()], + [new ConstantIntegerType(2), TypeCombinator::addNull($this->floatOrStringified())], + [new ConstantIntegerType(3), TypeCombinator::addNull($this->floatOrStringified())], + [new ConstantIntegerType(4), $this->floatOrStringified()], ]), ' SELECT DATE_DIFF(m.datetimeColumn, m.datetimeColumn), @@ -1309,47 +1257,19 @@ public function getTestData(): iterable ', ]; - /*yield 'sqrt function' => [ - $this->constantArray([ - [new ConstantIntegerType(1), $this->floatStringified()], - [new ConstantIntegerType(2), TypeCombinator::addNull($this->floatStringified())], - [new ConstantIntegerType(3), $this->floatStringified()], - ]), - ' - SELECT SQRT(m.intColumn), - SQRT(NULLIF(m.intColumn, 1)), - SQRT(1) - FROM QueryResult\Entities\Many m - ', - InstalledVersions::satisfies(new VersionParser(), 'doctrine/dbal', '<3') && PHP_VERSION_ID >= 80100 - ? 'sqrt(): Passing null to parameter #1 ($num) of type float is deprecated' - : null, - InstalledVersions::satisfies(new VersionParser(), 'doctrine/dbal', '>=3') && PHP_VERSION_ID >= 80100 - ? 'sqrt(): Passing null to parameter #1 ($num) of type float is deprecated' - : null, - ];*/ - yield 'length function' => [ $this->constantArray([ [ new ConstantIntegerType(1), - $this->hasTypedExpressions() - ? $this->uint() - : $this->uintStringified(), + $this->uint(), ], [ new ConstantIntegerType(2), - TypeCombinator::addNull( - $this->hasTypedExpressions() - ? $this->uint() - : $this->uintStringified() - ), + TypeCombinator::addNull($this->uint()), ], [ new ConstantIntegerType(3), - $this->hasTypedExpressions() - ? $this->uint() - : $this->uintStringified(), + $this->uint(), ], ]), ' @@ -1360,36 +1280,6 @@ public function getTestData(): iterable ', ]; - if (PHP_VERSION_ID >= 70400) { - yield 'locate function' => [ - $this->constantArray([ - [new ConstantIntegerType(1), $this->uintStringified()], - [new ConstantIntegerType(2), TypeCombinator::addNull($this->uintStringified())], - [new ConstantIntegerType(3), TypeCombinator::addNull($this->uintStringified())], - [new ConstantIntegerType(4), $this->uintStringified()], - ]), - ' - SELECT LOCATE(m.stringColumn, m.stringColumn, 0), - LOCATE(m.stringNullColumn, m.stringColumn, 0), - LOCATE(m.stringColumn, m.stringNullColumn, 0), - LOCATE(\'f\', \'foo\', 0) - FROM QueryResult\Entities\Many m - ', - null, - InstalledVersions::satisfies(new VersionParser(), 'doctrine/dbal', '>=3.4') - ? null - : ( - PHP_VERSION_ID >= 80100 - ? 'strpos(): Passing null to parameter #2 ($needle) of type string is deprecated' - : ( - PHP_VERSION_ID < 80000 - ? 'strpos(): Non-string needles will be interpreted as strings in the future. Use an explicit chr() call to preserve the current behavior' - : null - ) - ), - ]; - } - yield 'lower function' => [ $this->constantArray([ [new ConstantIntegerType(1), new StringType()], @@ -1406,10 +1296,10 @@ public function getTestData(): iterable yield 'mod function' => [ $this->constantArray([ - [new ConstantIntegerType(1), $this->uintStringified()], - [new ConstantIntegerType(2), TypeCombinator::addNull($this->uintStringified())], - [new ConstantIntegerType(3), TypeCombinator::addNull($this->uintStringified())], - [new ConstantIntegerType(4), $this->uintStringified()], + [new ConstantIntegerType(1), $this->uintOrStringified()], + [new ConstantIntegerType(2), TypeCombinator::addNull($this->uintOrStringified())], + [new ConstantIntegerType(3), TypeCombinator::addNull($this->uintOrStringified())], + [new ConstantIntegerType(4), $this->uintOrStringified()], ]), ' SELECT MOD(m.intColumn, 1), @@ -1422,7 +1312,7 @@ public function getTestData(): iterable yield 'mod function error' => [ $this->constantArray([ - [new ConstantIntegerType(1), TypeCombinator::addNull($this->uintStringified())], + [new ConstantIntegerType(1), TypeCombinator::addNull($this->uintOrStringified())], ]), ' SELECT MOD(10, NULLIF(m.intColumn, m.intColumn)) @@ -1481,15 +1371,15 @@ public function getTestData(): iterable yield 'identity function' => [ $this->constantArray([ - [new ConstantIntegerType(1), TypeCombinator::addNull($this->numericStringOrInt())], - [new ConstantIntegerType(2), $this->numericStringOrInt()], - [new ConstantIntegerType(3), TypeCombinator::addNull($this->numericStringOrInt())], + [new ConstantIntegerType(1), TypeCombinator::addNull($this->intOrStringified())], + [new ConstantIntegerType(2), $this->intOrStringified()], + [new ConstantIntegerType(3), TypeCombinator::addNull($this->intOrStringified())], [new ConstantIntegerType(4), TypeCombinator::addNull(new StringType())], [new ConstantIntegerType(5), TypeCombinator::addNull(new StringType())], - [new ConstantIntegerType(6), TypeCombinator::addNull($this->numericStringOrInt())], + [new ConstantIntegerType(6), TypeCombinator::addNull($this->intOrStringified())], [new ConstantIntegerType(7), TypeCombinator::addNull(new MixedType())], - [new ConstantIntegerType(8), TypeCombinator::addNull($this->numericStringOrInt())], - [new ConstantIntegerType(9), TypeCombinator::addNull($this->numericStringOrInt())], + [new ConstantIntegerType(8), TypeCombinator::addNull($this->intOrStringified())], + [new ConstantIntegerType(9), TypeCombinator::addNull($this->intOrStringified())], ]), ' SELECT IDENTITY(m.oneNull), @@ -1508,7 +1398,7 @@ public function getTestData(): iterable yield 'select nullable association' => [ $this->constantArray([ - [new ConstantIntegerType(1), TypeCombinator::addNull($this->numericStringOrInt())], + [new ConstantIntegerType(1), TypeCombinator::addNull($this->intOrStringified())], ]), ' SELECT DISTINCT(m.oneNull) @@ -1518,7 +1408,7 @@ public function getTestData(): iterable yield 'select non null association' => [ $this->constantArray([ - [new ConstantIntegerType(1), $this->numericStringOrInt()], + [new ConstantIntegerType(1), $this->intOrStringified()], ]), ' SELECT DISTINCT(m.one) @@ -1528,7 +1418,7 @@ public function getTestData(): iterable yield 'select default nullability association' => [ $this->constantArray([ - [new ConstantIntegerType(1), TypeCombinator::addNull($this->numericStringOrInt())], + [new ConstantIntegerType(1), TypeCombinator::addNull($this->intOrStringified())], ]), ' SELECT DISTINCT(m.oneDefaultNullability) @@ -1538,12 +1428,10 @@ public function getTestData(): iterable yield 'select non null association in aggregated query' => [ $this->constantArray([ - [new ConstantIntegerType(1), TypeCombinator::addNull($this->numericStringOrInt())], + [new ConstantIntegerType(1), TypeCombinator::addNull($this->intOrStringified())], [ new ConstantIntegerType(2), - $this->hasTypedExpressions() - ? $this->uint() - : $this->uintStringified(), + $this->uint(), ], ]), ' @@ -1589,6 +1477,133 @@ public function getTestData(): iterable FROM QueryResult\Entities\One o ', ]; + + yield 'unary minus' => [ + $this->constantArray([ + [new ConstantStringType('minusInt'), $this->stringifies() ? new ConstantStringType('-1') : new ConstantIntegerType(-1)], + [new ConstantStringType('minusFloat'), $this->stringifies() ? $this->numericString(false, true) : new ConstantFloatType(-0.1)], + [new ConstantStringType('minusIntRange'), $this->stringifies() ? $this->numericString(true, true) : IntegerRangeType::fromInterval(null, 0)], + ]), + ' + SELECT -1 as minusInt, + -0.1 as minusFloat, + -COUNT(o.intColumn) as minusIntRange + FROM QueryResult\Entities\One o + ', + ]; + + yield from $this->yieldConditionalDataset(); + } + + /** + * @return iterable + */ + private function yieldConditionalDataset(): iterable + { + if (property_exists(Column::class, 'enumType') && PHP_VERSION_ID >= 80100) { + assert(class_exists(StringEnum::class)); + assert(class_exists(IntEnum::class)); + + // https://github.com/doctrine/orm/issues/9622 + if (!$this->isDoctrine211()) { + yield 'enum' => [ + $this->constantArray([ + [new ConstantStringType('stringEnumColumn'), new ObjectType(StringEnum::class)], + [new ConstantStringType('intEnumColumn'), new ObjectType(IntEnum::class)], + [new ConstantStringType('stringEnumListColumn'), TypeCombinator::intersect(new ArrayType(new IntegerType(), new ObjectType(StringEnum::class)), new AccessoryArrayListType())], + ]), + ' + SELECT e.stringEnumColumn, e.intEnumColumn, e.stringEnumListColumn + FROM QueryResult\EntitiesEnum\EntityWithEnum e + ', + ]; + } + + yield 'enum in expression' => [ + $this->constantArray([ + [ + new ConstantIntegerType(1), + new IntersectionType([new StringType(), new AccessoryLowercaseStringType()]), + ], + [ + new ConstantIntegerType(2), + new IntegerType(), + ], + [ + new ConstantIntegerType(3), + $this->numericString(true, true), + ], + ]), + ' + SELECT COALESCE(e.stringEnumColumn, e.stringEnumColumn), + COALESCE(e.intEnumColumn, e.intEnumColumn), + COALESCE(e.intEnumOnStringColumn, e.intEnumOnStringColumn) + FROM QueryResult\EntitiesEnum\EntityWithEnum e + ', + ]; + } + + if (InstalledVersions::satisfies(new VersionParser(), 'doctrine/dbal', '>=4.2')) { + yield 'enum and smallfloat' => [ + $this->constantArray([ + [ + new ConstantStringType('enum'), + new UnionType([ + new ConstantStringType('a'), + new ConstantStringType('b'), + new ConstantStringType('c'), + ]), + ], + [ + new ConstantStringType('smallfloat'), + new FloatType(), + ], + ]), + ' + SELECT e.enum, e.smallfloat + FROM QueryResult\EntitiesDbal42\Dbal4Entity e + ', + ]; + } + + $ormVersion = InstalledVersions::getVersion('doctrine/orm'); + $hasOrm3 = $ormVersion !== null && strpos($ormVersion, '3.') === 0; + + if ($hasOrm3) { + return; + } + + yield 'date_add function' => [ + $this->constantArray([ + [new ConstantIntegerType(1), new StringType()], + [new ConstantIntegerType(2), TypeCombinator::addNull(new StringType())], + [new ConstantIntegerType(3), TypeCombinator::addNull(new StringType())], + [new ConstantIntegerType(4), new StringType()], + ]), + ' + SELECT DATE_ADD(m.datetimeColumn, m.intColumn, \'day\'), + DATE_ADD(m.stringNullColumn, m.intColumn, \'day\'), + DATE_ADD(m.datetimeColumn, NULLIF(m.intColumn, 1), \'day\'), + DATE_ADD(\'2020-01-01\', 7, \'day\') + FROM QueryResult\Entities\Many m + ', + ]; + + yield 'date_sub function' => [ + $this->constantArray([ + [new ConstantIntegerType(1), new StringType()], + [new ConstantIntegerType(2), TypeCombinator::addNull(new StringType())], + [new ConstantIntegerType(3), TypeCombinator::addNull(new StringType())], + [new ConstantIntegerType(4), new StringType()], + ]), + ' + SELECT DATE_SUB(m.datetimeColumn, m.intColumn, \'day\'), + DATE_SUB(m.stringNullColumn, m.intColumn, \'day\'), + DATE_SUB(m.datetimeColumn, NULLIF(m.intColumn, 1), \'day\'), + DATE_SUB(\'2020-01-01\', 7, \'day\') + FROM QueryResult\Entities\Many m + ', + ]; } /** @@ -1608,23 +1623,20 @@ private function constantArray(array $elements): Type return $builder->getArray(); } - private function numericStringOrInt(): Type - { - return new UnionType([ - new IntegerType(), - new IntersectionType([ - new StringType(), - new AccessoryNumericStringType(), - ]), - ]); - } - - private function numericString(): Type + private function numericString(bool $lowercase = false, bool $uppercase = false): Type { - return new IntersectionType([ + $types = [ new StringType(), new AccessoryNumericStringType(), - ]); + ]; + if ($lowercase) { + $types[] = new AccessoryLowercaseStringType(); + } + if ($uppercase) { + $types[] = new AccessoryUppercaseStringType(); + } + + return new IntersectionType($types); } private function uint(): Type @@ -1632,44 +1644,6 @@ 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 * @@ -1700,4 +1674,30 @@ private function isDoctrine211(): bool && version_compare($version, '2.12', '<'); } + private function stringifies(): bool + { + return PHP_VERSION_ID < 80100; + } + + private function intOrStringified(): Type + { + return $this->stringifies() + ? $this->numericString(true, true) + : new IntegerType(); + } + + private function uintOrStringified(): Type + { + return $this->stringifies() + ? $this->numericString(true, true) + : $this->uint(); + } + + private function floatOrStringified(): Type + { + return $this->stringifies() + ? $this->numericString(false, true) + : new FloatType(); + } + } diff --git a/tests/Type/Doctrine/QueryBuilderTypeSpecifyingExtensionTest.php b/tests/Type/Doctrine/QueryBuilderTypeSpecifyingExtensionTest.php index e0ebc471..9af21808 100644 --- a/tests/Type/Doctrine/QueryBuilderTypeSpecifyingExtensionTest.php +++ b/tests/Type/Doctrine/QueryBuilderTypeSpecifyingExtensionTest.php @@ -7,9 +7,6 @@ class QueryBuilderTypeSpecifyingExtensionTest extends TypeInferenceTestCase { - /** - * @return iterable - */ public function dataFileAsserts(): iterable { yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-219.php'); diff --git a/tests/Type/Doctrine/data/QueryResult/CustomIntType.php b/tests/Type/Doctrine/data/QueryResult/CustomIntType.php new file mode 100644 index 00000000..785bed33 --- /dev/null +++ b/tests/Type/Doctrine/data/QueryResult/CustomIntType.php @@ -0,0 +1,17 @@ +subOne = new SubOne(); diff --git a/tests/Type/Doctrine/data/QueryResult/Entities/Simple.php b/tests/Type/Doctrine/data/QueryResult/Entities/Simple.php new file mode 100644 index 00000000..5169a9e5 --- /dev/null +++ b/tests/Type/Doctrine/data/QueryResult/Entities/Simple.php @@ -0,0 +1,71 @@ + + * @Column(type="simple_array", enumType="QueryResult\EntitiesEnum\StringEnum") + */ + public $stringEnumListColumn; } diff --git a/tests/Type/Doctrine/data/QueryResult/config.neon b/tests/Type/Doctrine/data/QueryResult/config.neon index 5ce3210a..147e4f94 100644 --- a/tests/Type/Doctrine/data/QueryResult/config.neon +++ b/tests/Type/Doctrine/data/QueryResult/config.neon @@ -3,5 +3,3 @@ includes: parameters: doctrine: objectManagerLoader: entity-manager.php - featureToggles: - listType: true diff --git a/tests/Type/Doctrine/data/QueryResult/entity-manager.php b/tests/Type/Doctrine/data/QueryResult/entity-manager.php index c9ba38af..da6fd9fb 100644 --- a/tests/Type/Doctrine/data/QueryResult/entity-manager.php +++ b/tests/Type/Doctrine/data/QueryResult/entity-manager.php @@ -1,6 +1,8 @@ setProxyDir(__DIR__); @@ -29,6 +32,13 @@ ), 'QueryResult\EntitiesEnum\\'); } +if (InstalledVersions::satisfies(new VersionParser(), 'doctrine/dbal', '>=4.2')) { + $metadataDriver->addDriver(new AnnotationDriver( + new AnnotationReader(), + [__DIR__ . '/EntitiesDbal42'] + ), 'QueryResult\EntitiesDbal42\\'); +} + $config->setMetadataDriverImpl($metadataDriver); return new EntityManager( diff --git a/tests/Type/Doctrine/data/QueryResult/queryBuilderGetQuery.php b/tests/Type/Doctrine/data/QueryResult/queryBuilderGetQuery.php index a6ac1298..c0cf3068 100644 --- a/tests/Type/Doctrine/data/QueryResult/queryBuilderGetQuery.php +++ b/tests/Type/Doctrine/data/QueryResult/queryBuilderGetQuery.php @@ -9,6 +9,8 @@ use Doctrine\ORM\Query\Expr\From; use Doctrine\ORM\QueryBuilder; use QueryResult\Entities\Many; +use Type\Doctrine\data\QueryResult\Entities\Truck; +use Type\Doctrine\data\QueryResult\Entities\VehicleInterface; use function PHPStan\Testing\assertType; class QueryBuilderGetQuery @@ -131,6 +133,20 @@ public function testQueryResultTypeIsMixedWhenDQLIsInvalid(EntityManagerInterfac assertType('Doctrine\ORM\Query', $query); } + public function testQueryResultTypeIsMixedWhenDQLIsUsingAnInterfaceTypeDefinition(EntityManagerInterface $em): void + { + $vehicle = $this->createVehicule(); + + assertType(VehicleInterface::class, $vehicle); + + $query = $em->createQueryBuilder() + ->select('v') + ->from(get_class($vehicle), 'v') + ->getQuery(); + + assertType('Doctrine\ORM\Query', $query); + } + public function testQueryResultTypeIsVoidWithDeleteOrUpdate(EntityManagerInterface $em): void { $query = $em->getRepository(Many::class) @@ -246,6 +262,10 @@ public function testDynamicMethodCall( assertType('mixed', $result); } + private function createVehicule(): VehicleInterface + { + return new Truck(); + } /** * @param class-string $many diff --git a/tests/Type/Doctrine/data/QueryResult/queryResult.php b/tests/Type/Doctrine/data/QueryResult/queryResult.php index 02469e46..54eef205 100644 --- a/tests/Type/Doctrine/data/QueryResult/queryResult.php +++ b/tests/Type/Doctrine/data/QueryResult/queryResult.php @@ -144,11 +144,11 @@ public function testReturnTypeOfQueryMethodsWithExplicitObjectHydrationMode(Enti } /** - * Test that we properly infer the return type of Query methods with explicit hydration mode that is not HYDRATE_OBJECT + * Test that we properly infer the return type of Query methods with explicit hydration mode of HYDRATE_ARRAY * - * We are never able to infer the return type here + * We can infer the return type by changing every object by an array */ - public function testReturnTypeOfQueryMethodsWithExplicitNonObjectHydrationMode(EntityManagerInterface $em): void + public function testReturnTypeOfQueryMethodsWithExplicitArrayHydrationMode(EntityManagerInterface $em): void { $query = $em->createQuery(' SELECT m @@ -167,6 +167,11 @@ public function testReturnTypeOfQueryMethodsWithExplicitNonObjectHydrationMode(E 'mixed', $query->execute(null, AbstractQuery::HYDRATE_ARRAY) ); + + assertType( + 'array', + $query->getArrayResult() + ); assertType( 'mixed', $query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_ARRAY) @@ -185,6 +190,79 @@ public function testReturnTypeOfQueryMethodsWithExplicitNonObjectHydrationMode(E ); } + + /** + * Test that we properly infer the return type of Query methods with explicit hydration mode of HYDRATE_SIMPLEOBJECT + * + * We are never able to infer the return type here + */ + public function testReturnTypeOfQueryMethodsWithExplicitSimpleObjectHydrationMode(EntityManagerInterface $em): void + { + $query = $em->createQuery(' + SELECT m + FROM QueryResult\Entities\Many m + '); + + assertType( + 'list', + $query->getResult(AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'iterable', + $query->toIterable([], AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'list', + $query->execute(null, AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'list', + $query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'list', + $query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'QueryResult\Entities\Many', + $query->getSingleResult(AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'QueryResult\Entities\Many|null', + $query->getOneOrNullResult(AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + + $query = $em->createQuery(' + SELECT m.intColumn, m.stringNullColumn + FROM QueryResult\Entities\Many m + '); + + assertType( + 'mixed', + $query->getResult(AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'mixed', + $query->execute(null, AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'mixed', + $query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'mixed', + $query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'mixed', + $query->getSingleResult(AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'mixed', + $query->getOneOrNullResult(AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + } + /** * Test that we properly infer the return type of Query methods with explicit hydration mode that is not a constant value * diff --git a/tests/bootstrap.php b/tests/bootstrap.php index edf6599d..7994f793 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -4,6 +4,8 @@ require_once __DIR__ . '/../vendor/autoload.php'; +@ini_set('assert.exception', '0'); // @ deprecated in PHP 8.3 + PHPStanTestCase::getContainer(); require_once __DIR__ . '/orm-3-bootstrap.php';