diff --git a/.github/workflows/composer-require-checker.yml b/.github/workflows/composer-require-checker.yml index a857bce..a68facf 100644 --- a/.github/workflows/composer-require-checker.yml +++ b/.github/workflows/composer-require-checker.yml @@ -31,4 +31,4 @@ jobs: os: >- ['ubuntu-latest'] php: >- - ['8.1', '8.2', '8.3'] + ['8.3', '8.4'] diff --git a/.github/workflows/mssql.yml b/.github/workflows/mssql.yml index 689e4a9..bd53406 100644 --- a/.github/workflows/mssql.yml +++ b/.github/workflows/mssql.yml @@ -30,25 +30,16 @@ jobs: strategy: matrix: php: - - 8.1 - - 8.2 - 8.3 + - 8.4 mssql: - server: 2022-latest odbc-version: 18 flag: "-C" - - include: - - php: 8.3 - mssql: - server: 2017-latest - os: ubuntu-20.04 - - php: 8.3 - mssql: - server: 2019-latest - odbc-version: 18 - flag: "-C" + - server: 2019-latest + odbc-version: 18 + flag: "-C" services: mssql: @@ -113,4 +104,4 @@ jobs: uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} - files: ./coverage.xml + files: ./coverage.xml \ No newline at end of file diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index df1db8d..9843e4c 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -28,6 +28,6 @@ jobs: ['ubuntu-latest'] php: >- ['8.3'] - min-covered-msi: 100 + min-covered-msi: 97 secrets: STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} diff --git a/.github/workflows/mysql.yml b/.github/workflows/mysql.yml index 73ed2fd..a49b34e 100644 --- a/.github/workflows/mysql.yml +++ b/.github/workflows/mysql.yml @@ -37,9 +37,8 @@ jobs: - ubuntu-latest php: - - 8.1 - - 8.2 - 8.3 + - 8.4 mysql: - 5.7 diff --git a/.github/workflows/pgsql.yml b/.github/workflows/pgsql.yml index 076db87..677dfd6 100644 --- a/.github/workflows/pgsql.yml +++ b/.github/workflows/pgsql.yml @@ -37,9 +37,8 @@ jobs: - ubuntu-latest php: - - 8.1 - - 8.2 - 8.3 + - 8.4 pgsql: - 9 diff --git a/.github/workflows/sqlite.yml b/.github/workflows/sqlite.yml index b42130e..a145ddc 100644 --- a/.github/workflows/sqlite.yml +++ b/.github/workflows/sqlite.yml @@ -33,9 +33,8 @@ jobs: - ubuntu-latest php: - - 8.1 - - 8.2 - 8.3 + - 8.4 steps: - name: Checkout. diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index e33eca8..257bb73 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -29,4 +29,4 @@ jobs: os: >- ['ubuntu-latest'] php: >- - ['8.1', '8.2', '8.3'] + ['8.3', '8.4'] diff --git a/.gitignore b/.gitignore index c913400..17e349f 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,4 @@ composer.phar # PhpUnit /phpunit.phar /phpunit.xml -/.phpunit.cache +/.phpunit.cache \ No newline at end of file diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 0000000..16f7002 --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,35 @@ +in([ + $root.'/src', + $root.'/tests', + ]) + ->exclude([ + ]) + ->append([ + ]); + +return (new Config()) + ->setCacheFile(__DIR__ . '/runtime/cache/.php-cs-fixer.cache') + ->setParallelConfig(ParallelConfigFactory::detect( + // $filesPerProcess + 10, + // $processTimeout in seconds + 200, + // $maxProcesses + 10 + )) + ->setRules([ + '@PER-CS2.0' => true, + ]) + ->setFinder($finder); diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2fa7544 --- /dev/null +++ b/Makefile @@ -0,0 +1,122 @@ +.PHONY: m p pf pc pu ric riu i co cwn cs cm cu crc rdr rmc ep em es ex ea rp rm rs re x xd + +m: + @echo "================================================================================" + @echo " Data Cycle SYSTEM MENU (Make targets)" + @echo "================================================================================" + @echo "" + @echo "make p - Run PHP Psalm" + @echo "make pf FILE=src/Foo.php - Run PHP Psalm on a specific file" + @echo "make pc - Clear Psalm's cache" + @echo "make pu - Run PHPUnit tests" + @echo "make ric - Roave Infection Covered" + @echo "make riu - Roave Infection Uncovered" + @echo "make i - Infection Mutation Test" + @echo "make co - Composer outdated" + @echo "make cwn REPO=yiisoft/yii-demo VERSION=1.1.1 - Composer why-not" + @echo "make cs - PHP CS Fixer dry-run" + @echo "make cm - PHP CS Fixer fix" + @echo "make cu - Composer update" + @echo "make crc - Composer require checker" + @echo "make rdr - Rector Dry Run (see changes)" + @echo "make rmc - Rector (make changes)" + @echo "" + @echo "Extension & DB Test Suite Menu:" + @echo "make ep - Check PostgreSQL PHP extensions" + @echo "make em - Check MySQL PHP extensions" + @echo "make es - Check SQLite PHP extensions" + @echo "make ex - Check MSSQL PHP extensions" + @echo "make ea - Check ALL DB PHP extensions" + @echo "make rp - Run PHPUnit Pgsql test suite" + @echo "make rm - Run PHPUnit Mysql test suite" + @echo "make rs - Run PHPUnit Sqlite test suite" + @echo "make re - Run PHPUnit Mssql test suite" + @echo "================================================================================" + +p: + php vendor/bin/psalm + +pf: +ifndef FILE + $(error Please provide FILE, e.g. 'make pf FILE=src/Foo.php') +endif + php vendor/bin/psalm "$(FILE)" + +pc: + php vendor/bin/psalm --clear-cache + +pu: + php vendor/bin/phpunit + +ric: + php vendor/bin/roave-infection-static-analysis-plugin --only-covered + +riu: + php vendor/bin/roave-infection-static-analysis-plugin + +i: + php vendor/bin/infection + +co: + composer outdated + +cwn: +ifndef REPO + $(error Please provide REPO, e.g. 'make cwn REPO=yiisoft/yii-demo VERSION=1.1.1') +endif +ifndef VERSION + $(error Please provide VERSION, e.g. 'make cwn REPO=yiisoft/yii-demo VERSION=1.1.1') +endif + composer why-not $(REPO) $(VERSION) + +cs: + php vendor/bin/php-cs-fixer fix --dry-run --diff + +cm: + php vendor/bin/php-cs-fixer fix + +cu: + composer update + +crc: + php vendor/bin/composer-require-checker + +rdr: + php vendor/bin/rector process --dry-run + +rmc: + php vendor/bin/rector + +ep: + @echo "Checking for pdo_pgsql and pgsql extensions..." + @php -m | grep -E "^pdo_pgsql$$|^pgsql$$" || (echo 'Missing PostgreSQL extensions!'; exit 1) + +em: + @echo "Checking for pdo_mysql and mysql extensions..." + @php -m | grep -E "^pdo_mysql$$|^mysql$$" || (echo 'Missing MySQL extensions!'; exit 1) + +es: + @echo "Checking for pdo_sqlite and sqlite3 extensions..." + @php -m | grep -E "^pdo_sqlite$$|^sqlite3$$" || (echo 'Missing SQLite extensions!'; exit 1) + +ex: + @echo "Checking for pdo_sqlsrv and sqlsrv extensions..." + @php -m | grep -E "^pdo_sqlsrv$$|^sqlsrv$$" || (echo 'Missing MSSQL extensions!'; exit 1) + +ea: + $(MAKE) ep + $(MAKE) em + $(MAKE) es + $(MAKE) ex + +rp: + php vendor/bin/phpunit --testsuite Pgsql + +rm: + php vendor/bin/phpunit --testsuite Mysql + +rs: + php vendor/bin/phpunit --testsuite Sqlite + +re: + php vendor/bin/phpunit --testsuite Mssql \ No newline at end of file diff --git a/README.md b/README.md index 1073b09..9b9067e 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,14 @@ [![Latest Stable Version](https://poser.pugx.org/yiisoft/data-cycle/v)](https://packagist.org/packages/yiisoft/data-cycle) [![Total Downloads](https://poser.pugx.org/yiisoft/data-cycle/downloads)](https://packagist.org/packages/yiisoft/data-cycle) [![Code Coverage](https://codecov.io/gh/yiisoft/data-cycle/branch/master/graph/badge.svg)](https://codecov.io/gh/yiisoft/data-cycle) -[![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2Fdata%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/data-cycle/master) +[![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2Fdata-cycle%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/data-cycle/master) [![static analysis](https://github.com/yiisoft/data-cycle/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/data-cycle/actions?query=workflow%3A%22static+analysis%22) [![type-coverage](https://shepherd.dev/github/yiisoft/data-cycle/coverage.svg)](https://shepherd.dev/github/yiisoft/data-cycle) [![psalm-level](https://shepherd.dev/github/yiisoft/data-cycle/level.svg)](https://shepherd.dev/github/yiisoft/data-cycle) +[![Monthly Downloads](https://poser.pugx.org/yiisoft/data-cycle/d/monthly)](https://packagist.org/packages/yiisoft/data-cycle) +[![Daily Downloads](https://poser.pugx.org/yiisoft/data-cycle/d/daily)](https://packagist.org/packages/yiisoft/data-cycle) + There package provides [Cycle ORM](https://github.com/cycle/orm) query adapter for[Yii Data](https://github.com/yiisoft/data). For other integrations of Cycle ORM with Yii framework see [Yii Cycle](https://github.com/yiisoft/yii-cycle) package. diff --git a/composer.json b/composer.json index e7cc165..aaacbd1 100644 --- a/composer.json +++ b/composer.json @@ -32,20 +32,22 @@ "prefer-stable": true, "minimum-stability": "dev", "require": { - "php": "^8.1", + "php": "8.1 - 8.4", "ext-mbstring": "*", - "cycle/database": "^2.11", - "cycle/orm": "^2.9", - "yiisoft/data": "dev-master" + "cycle/database": "^2.15", + "cycle/orm": "^2.10.1", + "yiisoft/data": "dev-master", + "yiisoft/var-dumper": "^1.7" }, "require-dev": { - "maglnet/composer-require-checker": "^4.7", - "phpunit/phpunit": "^10.5", - "rector/rector": "^2.1.5", - "roave/infection-static-analysis-plugin": "^1.35", - "spatie/phpunit-watcher": "^1.24", - "vimeo/psalm": "^5.26", - "vlucas/phpdotenv": "^5.6" + "maglnet/composer-require-checker": "^4.16.1", + "friendsofphp/php-cs-fixer": "^3.87.1", + "phpunit/phpunit": "^12.3.8", + "rector/rector": "^2.1.6", + "roave/infection-static-analysis-plugin": ">=1.39", + "spatie/phpunit-watcher": ">=1.24.0", + "vimeo/psalm": "^6.13.1", + "vlucas/phpdotenv": "^5.6.2" }, "autoload": { "psr-4": { @@ -63,6 +65,7 @@ }, "config": { "sort-packages": true, + "bump-after-update": true, "allow-plugins": { "infection/extension-installer": true, "composer/package-versions-deprecated": true diff --git a/psalm.xml b/psalm.xml index 2a2f7e5..d81b606 100644 --- a/psalm.xml +++ b/psalm.xml @@ -1,6 +1,6 @@ + - - diff --git a/src/Reader/EntityReader.php b/src/Reader/EntityReader.php index c80f0c6..a6c771b 100644 --- a/src/Reader/EntityReader.php +++ b/src/Reader/EntityReader.php @@ -13,6 +13,7 @@ use Yiisoft\Data\Cycle\Exception\NotSupportedFilterException; use Yiisoft\Data\Cycle\Reader\FilterHandler\LikeHandler\LikeHandlerFactory; use Yiisoft\Data\Reader\DataReaderInterface; +use Yiisoft\Data\Reader\Filter\All; use Yiisoft\Data\Reader\FilterHandlerInterface; use Yiisoft\Data\Reader\FilterInterface; use Yiisoft\Data\Reader\Sort; @@ -34,7 +35,7 @@ final class EntityReader implements DataReaderInterface private ?int $limit = null; private int $offset = 0; private ?Sort $sorting = null; - private ?FilterInterface $filter = null; + private FilterInterface $filter; private CachedCount $countCache; private CachedCollection $itemsCache; private CachedCollection $oneItemCache; @@ -56,7 +57,9 @@ public function __construct(Select|SelectQuery $query) $likeHandler = LikeHandlerFactory::getLikeHandler($this->query->getDriver()?->getType() ?? 'SQLite'); $this->setFilterHandlers( new FilterHandler\AllHandler(), - new FilterHandler\AnyHandler(), + new FilterHandler\NoneHandler(), + new FilterHandler\AndXHandler(), + new FilterHandler\OrXHandler(), new FilterHandler\BetweenHandler(), new FilterHandler\EqualsHandler(), new FilterHandler\EqualsNullHandler(), @@ -68,8 +71,10 @@ public function __construct(Select|SelectQuery $query) $likeHandler, new FilterHandler\NotHandler(), ); + $this->filter = new All(); } + #[\Override] public function getSort(): ?Sort { return $this->sorting; @@ -78,6 +83,7 @@ public function getSort(): ?Sort /** * @psalm-mutation-free */ + #[\Override] public function withLimit(?int $limit): static { /** @psalm-suppress DocblockTypeContradiction */ @@ -85,6 +91,7 @@ public function withLimit(?int $limit): static throw new InvalidArgumentException('$limit must not be less than 0.'); } $new = clone $this; + if ($new->limit !== $limit) { $new->limit = $limit; $new->itemsCache = new CachedCollection(); @@ -92,12 +99,11 @@ public function withLimit(?int $limit): static return $new; } - /** - * @psalm-mutation-free - */ + #[\Override] public function withOffset(int $offset): static { $new = clone $this; + if ($new->offset !== $offset) { $new->offset = $offset; $new->itemsCache = new CachedCollection(); @@ -105,12 +111,11 @@ public function withOffset(int $offset): static return $new; } - /** - * @psalm-mutation-free - */ + #[\Override] public function withSort(?Sort $sort): static { $new = clone $this; + if ($new->sorting !== $sort) { $new->sorting = $sort; $new->itemsCache = new CachedCollection(); @@ -119,12 +124,11 @@ public function withSort(?Sort $sort): static return $new; } - /** - * @psalm-mutation-free - */ - public function withFilter(?FilterInterface $filter): static + #[\Override] + public function withFilter(FilterInterface $filter): static { $new = clone $this; + if ($new->filter !== $filter) { $new->filter = $filter; $new->itemsCache = new CachedCollection(); @@ -136,25 +140,30 @@ public function withFilter(?FilterInterface $filter): static } /** - * @psalm-mutation-free + * @return static */ + #[\Override] public function withAddedFilterHandlers(FilterHandlerInterface ...$filterHandlers): static { $new = clone $this; - /** @psalm-suppress ImpureMethodCall */ $new->setFilterHandlers(...$filterHandlers); - /** @psalm-suppress ImpureMethodCall */ $new->resetCountCache(); $new->itemsCache = new CachedCollection(); $new->oneItemCache = new CachedCollection(); return $new; } + #[\Override] public function count(): int { return $this->countCache->getCount(); } + /** + * @psalm-suppress LessSpecificImplementedReturnType + * @return iterable + */ + #[\Override] public function read(): iterable { if ($this->itemsCache->getCollection() === null) { @@ -164,23 +173,29 @@ public function read(): iterable return $this->itemsCache->getCollection(); } + #[\Override] public function readOne(): null|array|object { if (!$this->oneItemCache->isCollected()) { + /** @var array|object|null $item */ $item = $this->itemsCache->isCollected() // get the first item from a cached collection ? $this->itemsCache->getGenerator()->current() - // read data with limit 1 : $this->withLimit(1)->getIterator()->current(); $this->oneItemCache->setCollection($item === null ? [] : [$item]); } - - return $this->oneItemCache->getGenerator()->current(); + /** + * @psalm-suppress MixedReturnStatement + */ + return $this->oneItemCache->getGenerator()->valid() ? + $this->oneItemCache->getGenerator()->current() + : null; } /** * Get Iterator without caching */ + #[\Override] public function getIterator(): Generator { yield from $this->itemsCache->getCollection() ?? $this->buildSelectQuery()->getIterator(); @@ -189,7 +204,7 @@ public function getIterator(): Generator public function getSql(): string { $query = $this->buildSelectQuery(); - return (string)($query instanceof Select ? $query->buildQuery() : $query); + return (string) ($query instanceof Select ? $query->buildQuery() : $query); } private function setFilterHandlers(FilterHandlerInterface ...$filterHandlers): void @@ -206,7 +221,7 @@ private function setFilterHandlers(FilterHandlerInterface ...$filterHandlers): v private function buildSelectQuery(): SelectQuery|Select { $newQuery = clone $this->query; - if ($this->offset !== 0) { + if ($this->offset >= 0 && $this->offset !== 0) { $newQuery->offset($this->offset); } if ($this->sorting !== null) { @@ -215,7 +230,7 @@ private function buildSelectQuery(): SelectQuery|Select if ($this->limit !== null) { $newQuery->limit($this->limit); } - if ($this->filter !== null) { + if (!($this->filter instanceof All)) { $newQuery->andWhere($this->makeFilterClosure($this->filter)); } return $newQuery; @@ -235,37 +250,49 @@ private function makeFilterClosure(FilterInterface $filter): Closure private function resetCountCache(): void { $newQuery = clone $this->query; - if ($this->filter !== null) { + + if (!$this->filter instanceof All) { $newQuery->andWhere($this->makeFilterClosure($this->filter)); } $this->countCache = new CachedCount($newQuery); } + /** + * @psalm-param array $criteria + * @psalm-return array + * @return array + */ private function normalizeSortingCriteria(array $criteria): array { foreach ($criteria as $field => $direction) { if (is_int($direction)) { + /** @var 'ASC'|'DESC' $direction */ $direction = match ($direction) { SORT_DESC => 'DESC', default => 'ASC', }; } - $criteria[$field] = $direction; + /** @var 'ASC'|'DESC'|string $direction */ + $criteria[$field] = $direction; // Always string! } + /** @var array $criteria */ return $criteria; } - public function getFilter(): ?FilterInterface + #[\Override] + public function getFilter(): FilterInterface { return $this->filter; } + #[\Override] public function getLimit(): ?int { return $this->limit; } + #[\Override] public function getOffset(): int { return $this->offset; diff --git a/src/Reader/FilterHandler/AllHandler.php b/src/Reader/FilterHandler/AllHandler.php index 9a3a16a..e7318eb 100644 --- a/src/Reader/FilterHandler/AllHandler.php +++ b/src/Reader/FilterHandler/AllHandler.php @@ -4,8 +4,6 @@ namespace Yiisoft\Data\Cycle\Reader\FilterHandler; -use Cycle\ORM\Select\QueryBuilder; -use Yiisoft\Data\Cycle\Exception\NotSupportedFilterException; use Yiisoft\Data\Cycle\Reader\QueryBuilderFilterHandler; use Yiisoft\Data\Reader\Filter\All; use Yiisoft\Data\Reader\FilterHandlerInterface; @@ -13,25 +11,15 @@ final class AllHandler implements QueryBuilderFilterHandler, FilterHandlerInterface { + #[\Override] public function getFilterClass(): string { return All::class; } + #[\Override] public function getAsWhereArguments(FilterInterface $filter, array $handlers): array { - /** @var All $filter */ - - return [ - static function (QueryBuilder $select) use ($filter, $handlers) { - foreach ($filter->getFilters() as $subFilter) { - $handler = $handlers[$subFilter::class] ?? null; - if ($handler === null) { - throw new NotSupportedFilterException($subFilter::class); - } - $select->andWhere(...$handler->getAsWhereArguments($subFilter, $handlers)); - } - }, - ]; + return []; } } diff --git a/src/Reader/FilterHandler/AndXHandler.php b/src/Reader/FilterHandler/AndXHandler.php new file mode 100644 index 0000000..036d7e9 --- /dev/null +++ b/src/Reader/FilterHandler/AndXHandler.php @@ -0,0 +1,39 @@ +filters as $subFilter) { + $handler = $handlers[$subFilter::class] ?? null; + if ($handler === null) { + throw new NotSupportedFilterException($subFilter::class); + } + $select->andWhere(...$handler->getAsWhereArguments($subFilter, $handlers)); + } + }, + ]; + } +} diff --git a/src/Reader/FilterHandler/BetweenHandler.php b/src/Reader/FilterHandler/BetweenHandler.php index 4afea6a..3b739e5 100644 --- a/src/Reader/FilterHandler/BetweenHandler.php +++ b/src/Reader/FilterHandler/BetweenHandler.php @@ -11,15 +11,17 @@ final class BetweenHandler implements QueryBuilderFilterHandler, FilterHandlerInterface { + #[\Override] public function getFilterClass(): string { return Between::class; } + #[\Override] public function getAsWhereArguments(FilterInterface $filter, array $handlers): array { /** @var Between $filter */ - return [$filter->getField(), 'between', $filter->getMinValue(), $filter->getMaxValue()]; + return [$filter->field, 'between', $filter->minValue, $filter->maxValue]; } } diff --git a/src/Reader/FilterHandler/EqualsHandler.php b/src/Reader/FilterHandler/EqualsHandler.php index d034a10..933be1a 100644 --- a/src/Reader/FilterHandler/EqualsHandler.php +++ b/src/Reader/FilterHandler/EqualsHandler.php @@ -11,15 +11,17 @@ final class EqualsHandler implements QueryBuilderFilterHandler, FilterHandlerInterface { + #[\Override] public function getFilterClass(): string { return Equals::class; } + #[\Override] public function getAsWhereArguments(FilterInterface $filter, array $handlers): array { /** @var Equals $filter */ - return [$filter->getField(), '=', $filter->getValue()]; + return [$filter->field, '=', $filter->value]; } } diff --git a/src/Reader/FilterHandler/EqualsNullHandler.php b/src/Reader/FilterHandler/EqualsNullHandler.php index 8f97665..7c8f469 100644 --- a/src/Reader/FilterHandler/EqualsNullHandler.php +++ b/src/Reader/FilterHandler/EqualsNullHandler.php @@ -11,15 +11,17 @@ final class EqualsNullHandler implements QueryBuilderFilterHandler, FilterHandlerInterface { + #[\Override] public function getFilterClass(): string { return EqualsNull::class; } + #[\Override] public function getAsWhereArguments(FilterInterface $filter, array $handlers): array { /** @var EqualsNull $filter */ - return [$filter->getField(), '=', null]; + return [$filter->field, '=', null]; } } diff --git a/src/Reader/FilterHandler/GreaterThanHandler.php b/src/Reader/FilterHandler/GreaterThanHandler.php index d7283a3..f152e3f 100644 --- a/src/Reader/FilterHandler/GreaterThanHandler.php +++ b/src/Reader/FilterHandler/GreaterThanHandler.php @@ -11,15 +11,17 @@ final class GreaterThanHandler implements QueryBuilderFilterHandler, FilterHandlerInterface { + #[\Override] public function getFilterClass(): string { return GreaterThan::class; } + #[\Override] public function getAsWhereArguments(FilterInterface $filter, array $handlers): array { /** @var GreaterThan $filter */ - return [$filter->getField(), '>', $filter->getValue()]; + return [$filter->field, '>', $filter->value]; } } diff --git a/src/Reader/FilterHandler/GreaterThanOrEqualHandler.php b/src/Reader/FilterHandler/GreaterThanOrEqualHandler.php index 53b1776..ee3c6ce 100644 --- a/src/Reader/FilterHandler/GreaterThanOrEqualHandler.php +++ b/src/Reader/FilterHandler/GreaterThanOrEqualHandler.php @@ -11,15 +11,17 @@ final class GreaterThanOrEqualHandler implements QueryBuilderFilterHandler, FilterHandlerInterface { + #[\Override] public function getFilterClass(): string { return GreaterThanOrEqual::class; } + #[\Override] public function getAsWhereArguments(FilterInterface $filter, array $handlers): array { /** @var GreaterThanOrEqual $filter */ - return [$filter->getField(), '>=', $filter->getValue()]; + return [$filter->field, '>=', $filter->value]; } } diff --git a/src/Reader/FilterHandler/InHandler.php b/src/Reader/FilterHandler/InHandler.php index 6f44c2d..3ed693d 100644 --- a/src/Reader/FilterHandler/InHandler.php +++ b/src/Reader/FilterHandler/InHandler.php @@ -12,15 +12,17 @@ final class InHandler implements QueryBuilderFilterHandler, FilterHandlerInterface { + #[\Override] public function getFilterClass(): string { return In::class; } + #[\Override] public function getAsWhereArguments(FilterInterface $filter, array $handlers): array { /** @var In $filter */ - return [$filter->getField(), 'in', new Parameter($filter->getValues())]; + return [$filter->field, 'in', new Parameter($filter->values)]; } } diff --git a/src/Reader/FilterHandler/LessThanHandler.php b/src/Reader/FilterHandler/LessThanHandler.php index a35fa86..3ee5997 100644 --- a/src/Reader/FilterHandler/LessThanHandler.php +++ b/src/Reader/FilterHandler/LessThanHandler.php @@ -11,15 +11,17 @@ final class LessThanHandler implements QueryBuilderFilterHandler, FilterHandlerInterface { + #[\Override] public function getFilterClass(): string { return LessThan::class; } + #[\Override] public function getAsWhereArguments(FilterInterface $filter, array $handlers): array { /** @var LessThan $filter */ - return [$filter->getField(), '<', $filter->getValue()]; + return [$filter->field, '<', $filter->value]; } } diff --git a/src/Reader/FilterHandler/LessThanOrEqualHandler.php b/src/Reader/FilterHandler/LessThanOrEqualHandler.php index 22bddf9..a5948ad 100644 --- a/src/Reader/FilterHandler/LessThanOrEqualHandler.php +++ b/src/Reader/FilterHandler/LessThanOrEqualHandler.php @@ -11,15 +11,17 @@ final class LessThanOrEqualHandler implements QueryBuilderFilterHandler, FilterHandlerInterface { + #[\Override] public function getFilterClass(): string { return LessThanOrEqual::class; } + #[\Override] public function getAsWhereArguments(FilterInterface $filter, array $handlers): array { /** @var LessThanOrEqual $filter */ - return [$filter->getField(), '<=', $filter->getValue()]; + return [$filter->field, '<=', $filter->value]; } } diff --git a/src/Reader/FilterHandler/LikeHandler/BaseLikeHandler.php b/src/Reader/FilterHandler/LikeHandler/BaseLikeHandler.php index 1b286b2..3ac7943 100644 --- a/src/Reader/FilterHandler/LikeHandler/BaseLikeHandler.php +++ b/src/Reader/FilterHandler/LikeHandler/BaseLikeHandler.php @@ -5,6 +5,7 @@ namespace Yiisoft\Data\Cycle\Reader\FilterHandler\LikeHandler; use Yiisoft\Data\Reader\Filter\Like; +use Yiisoft\Data\Reader\Filter\LikeMode; use Yiisoft\Data\Reader\FilterHandlerInterface; abstract class BaseLikeHandler implements FilterHandlerInterface @@ -15,13 +16,24 @@ abstract class BaseLikeHandler implements FilterHandlerInterface '\\' => '\\\\', ]; + #[\Override] public function getFilterClass(): string { return Like::class; } - protected function prepareValue(string $value): string + /** + * Prepare the SQL LIKE pattern according to LikeMode. + * Accepts LikeMode as a parameter, defaulting to Contains for backward compatibility. + */ + protected function prepareValue(string $value, LikeMode $mode = LikeMode::Contains): string { - return '%' . strtr($value, $this->escapingReplacements) . '%'; + $escapedValue = strtr($value, $this->escapingReplacements); + + return match ($mode) { + LikeMode::Contains => '%' . $escapedValue . '%', + LikeMode::StartsWith => $escapedValue . '%', + LikeMode::EndsWith => '%' . $escapedValue, + }; } } diff --git a/src/Reader/FilterHandler/LikeHandler/MysqlLikeHandler.php b/src/Reader/FilterHandler/LikeHandler/MysqlLikeHandler.php index 2150256..1e719a9 100644 --- a/src/Reader/FilterHandler/LikeHandler/MysqlLikeHandler.php +++ b/src/Reader/FilterHandler/LikeHandler/MysqlLikeHandler.php @@ -10,14 +10,16 @@ final class MysqlLikeHandler extends BaseLikeHandler implements QueryBuilderFilterHandler { + #[\Override] public function getAsWhereArguments(FilterInterface $filter, array $handlers): array { /** @var Like $filter */ + $pattern = $this->prepareValue($filter->value, $filter->mode); - if ($filter->getCaseSensitive() !== true) { - return [$filter->getField(), 'like', '%' . $this->prepareValue($filter->getValue()) . '%']; + if ($filter->caseSensitive !== true) { + return [$filter->field, 'like', $pattern]; } - return [$filter->getField(), 'like binary', $this->prepareValue($filter->getValue())]; + return [$filter->field, 'like binary', $pattern]; } } diff --git a/src/Reader/FilterHandler/LikeHandler/PostgresLikeHandler.php b/src/Reader/FilterHandler/LikeHandler/PostgresLikeHandler.php index 91dd8f1..28db13c 100644 --- a/src/Reader/FilterHandler/LikeHandler/PostgresLikeHandler.php +++ b/src/Reader/FilterHandler/LikeHandler/PostgresLikeHandler.php @@ -10,14 +10,16 @@ final class PostgresLikeHandler extends BaseLikeHandler implements QueryBuilderFilterHandler { + #[\Override] public function getAsWhereArguments(FilterInterface $filter, array $handlers): array { /** @var Like $filter */ + $pattern = $this->prepareValue($filter->value, $filter->mode); - if ($filter->getCaseSensitive() !== true) { - return [$filter->getField(), 'ilike', $this->prepareValue($filter->getValue())]; + if ($filter->caseSensitive !== true) { + return [$filter->field, 'ilike', $pattern]; } - return [$filter->getField(), 'like', $this->prepareValue($filter->getValue())]; + return [$filter->field, 'like', $pattern]; } } diff --git a/src/Reader/FilterHandler/LikeHandler/SqlServerLikeHandler.php b/src/Reader/FilterHandler/LikeHandler/SqlServerLikeHandler.php index 43f8b19..796cbed 100644 --- a/src/Reader/FilterHandler/LikeHandler/SqlServerLikeHandler.php +++ b/src/Reader/FilterHandler/LikeHandler/SqlServerLikeHandler.php @@ -16,14 +16,16 @@ public function __construct() unset($this->escapingReplacements['\\']); } + #[\Override] public function getAsWhereArguments(FilterInterface $filter, array $handlers): array { /** @var Like $filter */ + $pattern = $this->prepareValue($filter->value, $filter->mode); - if ($filter->getCaseSensitive() === true) { + if ($filter->caseSensitive === true) { throw new NotSupportedFilterOptionException(optionName: 'caseSensitive', driverType: 'SQLServer'); } - return [$filter->getField(), 'like', $this->prepareValue($filter->getValue())]; + return [$filter->field, 'like', $pattern]; } } diff --git a/src/Reader/FilterHandler/LikeHandler/SqliteLikeHandler.php b/src/Reader/FilterHandler/LikeHandler/SqliteLikeHandler.php index 7d51508..cc1e600 100644 --- a/src/Reader/FilterHandler/LikeHandler/SqliteLikeHandler.php +++ b/src/Reader/FilterHandler/LikeHandler/SqliteLikeHandler.php @@ -7,23 +7,44 @@ use Yiisoft\Data\Cycle\Exception\NotSupportedFilterOptionException; use Yiisoft\Data\Cycle\Reader\QueryBuilderFilterHandler; use Yiisoft\Data\Reader\Filter\Like; +use Yiisoft\Data\Reader\Filter\LikeMode; use Yiisoft\Data\Reader\FilterInterface; final class SqliteLikeHandler extends BaseLikeHandler implements QueryBuilderFilterHandler { - public function __construct() - { - unset($this->escapingReplacements['\\']); - } + protected array $escapingReplacements = [ + '%' => '\%', + '_' => '\_', + ]; + #[\Override] + /** + * @param FilterInterface $filter + * @psalm-param Like $filter + */ public function getAsWhereArguments(FilterInterface $filter, array $handlers): array { + $allowedModes = [LikeMode::Contains, LikeMode::StartsWith, LikeMode::EndsWith]; + /** @var Like $filter */ + $modeName = $filter->mode->name; + + if (!in_array($filter->mode, $allowedModes, true)) { + throw new NotSupportedFilterOptionException( + sprintf('LikeMode "%s" is not supported by SqliteLikeHandler.', $modeName), + 'sqlite', + ); + } + + // The above escaping replacements will be used to build the pattern + // in the event of escape characters (% or _) being found in the $filter->value + // Sqlite does not have the ESCAPE command available + $pattern = $this->prepareValue($filter->value, $filter->mode); - if ($filter->getCaseSensitive() === true) { + if ($filter->caseSensitive === true) { throw new NotSupportedFilterOptionException(optionName: 'caseSensitive', driverType: 'SQLite'); } - return [$filter->getField(), 'like', $this->prepareValue($filter->getValue())]; + return [$filter->field, 'like', $pattern]; } } diff --git a/src/Reader/FilterHandler/NoneHandler.php b/src/Reader/FilterHandler/NoneHandler.php new file mode 100644 index 0000000..6aee1c5 --- /dev/null +++ b/src/Reader/FilterHandler/NoneHandler.php @@ -0,0 +1,26 @@ +convertFilter($filter->getFilter()); - $handledFilter = $convertedFilter instanceof Not ? $convertedFilter->getFilter() : $convertedFilter; + $convertedFilter = $this->convertFilter($filter->filter); + $handledFilter = $convertedFilter instanceof Not ? $convertedFilter->filter : $convertedFilter; $handler = $handlers[$handledFilter::class] ?? null; if ($handler === null) { throw new NotSupportedFilterException($handledFilter::class); @@ -44,12 +46,20 @@ public function getAsWhereArguments(FilterInterface $filter, array $handlers): a return $where; } - $operator = $where[1]; - $where[1] = match ($operator) { - 'between', 'in', 'like' => "not $operator", - '=' => '!=', - default => $operator, - }; + $operator = (string) $where[1]; + if ($operator === 'between') { + $where[1] = 'not between'; + } elseif ($operator === 'in') { + $where[1] = 'not in'; + } elseif ($operator === 'like') { + $where[1] = 'not like'; + } elseif ($operator === 'ilike') { + $where[1] = 'not ilike'; + } elseif ($operator === '=') { + $where[1] = '!='; + } else { + $where[1] = $operator; + } return $where; } @@ -59,22 +69,22 @@ private function convertFilter(FilterInterface $filter, int $notCount = 1): Filt $handler = $this; return match ($filter::class) { - All::class => new Any( + AndX::class => new OrX( ...array_map( - static fn (FilterInterface $subFilter): FilterInterface => $handler->convertFilter($subFilter), - $filter->getFilters(), + static fn(FilterInterface $subFilter): FilterInterface => $handler->convertFilter($subFilter), + $filter->filters, ), ), - Any::class => new All( + OrX::class => new AndX( ...array_map( - static fn (FilterInterface $subFilter): FilterInterface => $handler->convertFilter($subFilter), - $filter->getFilters(), + static fn(FilterInterface $subFilter): FilterInterface => $handler->convertFilter($subFilter), + $filter->filters, ), ), - GreaterThan::class => new LessThanOrEqual($filter->getField(), $filter->getValue()), - GreaterThanOrEqual::class => new LessThan($filter->getField(), $filter->getValue()), - LessThan::class => new GreaterThanOrEqual($filter->getField(), $filter->getValue()), - LessThanOrEqual::class => new GreaterThan($filter->getField(), $filter->getValue()), + GreaterThan::class => new LessThanOrEqual($filter->field, $filter->value), + GreaterThanOrEqual::class => new LessThan($filter->field, $filter->value), + LessThan::class => new GreaterThanOrEqual($filter->field, $filter->value), + LessThanOrEqual::class => new GreaterThan($filter->field, $filter->value), Between::class, Equals::class, EqualsNull::class, In::class, Like::class => new Not($filter), Not::class => $this->convertNot($filter, $notCount), default => $filter, @@ -85,10 +95,10 @@ private function convertNot(Not $filter, int $notCount): FilterInterface { $notCount++; - if ($filter->getFilter() instanceof Not) { - return $this->convertFilter($filter->getFilter(), $notCount); + if ($filter->filter instanceof Not) { + return $this->convertFilter($filter->filter, $notCount); } - return $notCount % 2 === 1 ? new Not($filter->getFilter()) : $filter->getFilter(); + return $notCount % 2 === 1 ? new Not($filter->filter) : $filter->filter; } } diff --git a/src/Reader/FilterHandler/AnyHandler.php b/src/Reader/FilterHandler/OrXHandler.php similarity index 79% rename from src/Reader/FilterHandler/AnyHandler.php rename to src/Reader/FilterHandler/OrXHandler.php index 5668951..0188d55 100644 --- a/src/Reader/FilterHandler/AnyHandler.php +++ b/src/Reader/FilterHandler/OrXHandler.php @@ -7,24 +7,26 @@ use Cycle\ORM\Select\QueryBuilder; use Yiisoft\Data\Cycle\Exception\NotSupportedFilterException; use Yiisoft\Data\Cycle\Reader\QueryBuilderFilterHandler; -use Yiisoft\Data\Reader\Filter\Any; +use Yiisoft\Data\Reader\Filter\OrX; use Yiisoft\Data\Reader\FilterHandlerInterface; use Yiisoft\Data\Reader\FilterInterface; -final class AnyHandler implements QueryBuilderFilterHandler, FilterHandlerInterface +final class OrXHandler implements QueryBuilderFilterHandler, FilterHandlerInterface { + #[\Override] public function getFilterClass(): string { - return Any::class; + return OrX::class; } + #[\Override] public function getAsWhereArguments(FilterInterface $filter, array $handlers): array { - /** @var Any $filter */ + /** @var OrX $filter */ return [ static function (QueryBuilder $select) use ($filter, $handlers) { - foreach ($filter->getFilters() as $subFilter) { + foreach ($filter->filters as $subFilter) { $handler = $handlers[$subFilter::class] ?? null; if ($handler === null) { throw new NotSupportedFilterException($subFilter::class); diff --git a/src/Writer/EntityWriter.php b/src/Writer/EntityWriter.php index 0891c09..f793bef 100644 --- a/src/Writer/EntityWriter.php +++ b/src/Writer/EntityWriter.php @@ -17,17 +17,25 @@ public function __construct(private EntityManagerInterface $entityManager) /** * @throws Throwable */ + #[\Override] public function write(iterable $items): void { foreach ($items as $entity) { + if (!is_object($entity)) { + throw new \InvalidArgumentException('Entity must be an object.'); + } $this->entityManager->persist($entity); } $this->entityManager->run(); } + #[\Override] public function delete(iterable $items): void { foreach ($items as $entity) { + if (!is_object($entity)) { + throw new \InvalidArgumentException('Entity must be an object.'); + } $this->entityManager->delete($entity); } $this->entityManager->run(); diff --git a/tests/.env b/tests/.env index 7acf669..ffbe166 100644 --- a/tests/.env +++ b/tests/.env @@ -1,12 +1,12 @@ CYCLE_MYSQL_DATABASE=spiral CYCLE_MYSQL_HOST=127.0.0.1 -CYCLE_MYSQL_PORT=13306 +CYCLE_MYSQL_PORT=3306 CYCLE_MYSQL_USER=root -CYCLE_MYSQL_PASSWORD=root +CYCLE_MYSQL_PASSWORD= CYCLE_PGSQL_DATABASE=spiral CYCLE_PGSQL_HOST=127.0.0.1 -CYCLE_PGSQL_PORT=15432 +CYCLE_PGSQL_PORT=5433 CYCLE_PGSQL_USER=postgres CYCLE_PGSQL_PASSWORD=postgres diff --git a/tests/Feature/Base/Reader/BaseEntityReaderTestCase.php b/tests/Feature/Base/Reader/BaseEntityReaderTestCase.php index 45b01a2..9753e30 100644 --- a/tests/Feature/Base/Reader/BaseEntityReaderTestCase.php +++ b/tests/Feature/Base/Reader/BaseEntityReaderTestCase.php @@ -4,11 +4,14 @@ namespace Yiisoft\Data\Cycle\Tests\Feature\Base\Reader; +use Cycle\Database\Query\SelectQuery; +use Cycle\ORM\Select; use Cycle\Database\Exception\StatementException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Yiisoft\Data\Cycle\Exception\NotSupportedFilterException; use Yiisoft\Data\Cycle\Reader\Cache\CachedCollection; +use Yiisoft\Data\Cycle\Reader\Cache\CachedCount; use Yiisoft\Data\Cycle\Reader\EntityReader; use Yiisoft\Data\Cycle\Tests\Feature\DataTrait; use Yiisoft\Data\Cycle\Tests\Support\NotSupportedFilter; @@ -36,15 +39,17 @@ public function testReadOneFromItemsCache(): void $reader = (new EntityReader($this->select('user')))->withLimit(3); $ref = (new \ReflectionProperty($reader, 'itemsCache')); - $ref->setAccessible(true); - self::assertFalse($ref->getValue($reader)->isCollected()); + /** @var \Yiisoft\Data\Cycle\Reader\Cache\CachedCollection $itemsCache */ + $itemsCache = $ref->getValue($reader); + + self::assertFalse($itemsCache->isCollected()); $reader->read(); - self::assertTrue($ref->getValue($reader)->isCollected()); + self::assertTrue($itemsCache->isCollected()); $this->assertFixtures([0], [$reader->readOne()]); - self::assertEquals($ref->getValue($reader)->getCollection()[0], $reader->readOne()); + self::assertEquals(iterator_to_array($itemsCache->getCollection(), false)[0], $reader->readOne()); } public function testGetIterator(): void @@ -53,7 +58,6 @@ public function testGetIterator(): void $this->assertFixtures([0], [\iterator_to_array($reader->getIterator())[0]]); $ref = (new \ReflectionProperty($reader, 'itemsCache')); - $ref->setAccessible(true); $cache = new CachedCollection(); $cache->setCollection([['foo' => 'bar']]); @@ -77,7 +81,9 @@ public function testWithSort(): void ->withSort(Sort::only(['number'])->withOrderString('-number')); $this->assertFixtures(array_reverse(range(0, 4)), $reader->read()); - self::assertSame('-number', $reader->getSort()->getOrderAsString()); + $sort = $reader->getSort(); + self::assertNotNull($sort, 'Sort should not be null'); + self::assertSame('-number', $sort->getOrderAsString()); } public function testGetSort(): void @@ -96,7 +102,7 @@ public function testCount(): void { $reader = new EntityReader($this->select('user')); - self::assertSame(count(self::$fixtures), $reader->count()); + self::assertSame(count($this->getFixtures()), $reader->count()); } /** @@ -108,7 +114,7 @@ public function testCountWithLimit(): void $this->select('user'), ))->withLimit(1); - self::assertSame(count(self::$fixtures), $reader->count()); + self::assertSame(count($this->getFixtures()), $reader->count()); } public function testCountWithFilter(): void @@ -130,6 +136,7 @@ public function testLimit(): void public function testLimitException(): void { $this->expectException(\InvalidArgumentException::class); + /** @psalm-suppress InvalidArgument **/ (new EntityReader($this->select('user')))->withLimit(-1); } @@ -195,4 +202,366 @@ public function testMakeFilterClosureException(): void $this->expectExceptionMessage(sprintf('Filter "%s" is not supported.', NotSupportedFilter::class)); $reader->withFilter(new NotSupportedFilter()); } + + public function testConstructorClonesQuery(): void + { + $query = $this->select('user'); + $reader = new EntityReader($query); + + $ref = new \ReflectionProperty($reader, 'query'); + /** @var Select|SelectQuery $internalQuery */ + $internalQuery = $ref->getValue($reader); + + $this->assertNotSame($query, $internalQuery, 'Query should be cloned and not the same instance'); + } + + public function testWithLimitZeroDoesNotThrow(): void + { + $reader = new EntityReader($this->select('user')); + $reader->withLimit(0); + $this->assertTrue(true, 'withLimit(0) should not throw'); + } + + public function testWithLimitThrowsOnNegative(): void + { + $this->expectException(\InvalidArgumentException::class); + /** @psalm-suppress InvalidArgument **/ + (new EntityReader($this->select('user')))->withLimit(-1); + } + + public function testReadOneReturnsOnlySingleItem(): void + { + $reader = (new EntityReader($this->select('user'))); + $result = $reader->readOne(); + + // Ensure result is either array/object/null + $this->assertTrue(is_array($result) || is_object($result) || $result === null); + + // If it's an array, ensure it is a single record, not a list of 2+ + // For example, if your record is array-like: + if (is_array($result)) { + // Check for an indexed array (should not be!) + $this->assertFalse(array_is_list($result) && count($result) > 1, 'readOne() must not return more than one record.'); + } + } + + public function testReadOneReturnsExactlyOneRecord(): void + { + $reader = (new EntityReader($this->select('user'))); + $result = $reader->readOne(); + + // It must not be an array of multiple records + if (is_array($result) && array_is_list($result)) { + $this->assertCount(1, $result, 'readOne() should return only one record, not a list or more than one.'); + } + // If your implementation returns a single associative array or object, that's fine + $this->assertTrue(is_array($result) || is_object($result) || $result === null); + } + + public function testBuildSelectQueryReturnsClone(): void + { + $reader = new EntityReader($this->select('user')); + + $ref = new \ReflectionMethod($reader, 'buildSelectQuery'); + + /** @var array $result */ + $result = $ref->invoke($reader); + + $queryRef = new \ReflectionProperty($reader, 'query'); + /** @var Select $original */ + $original = $queryRef->getValue($reader); + + $this->assertNotSame($original, $result, 'buildSelectQuery should return a clone, not the original query'); + } + + public function testBuildSelectQueryWithZeroOffset(): void + { + $reader = new EntityReader($this->select('user')); + + $offsetProp = new \ReflectionProperty($reader, 'offset'); + $offsetProp->setValue($reader, 0); + + $method = new \ReflectionMethod($reader, 'buildSelectQuery'); + + /** @var Select|SelectQuery $result */ + $result = $method->invoke($reader); + } + + public function testResetCountCacheUsesClonedQueryForCachedCount(): void + { + $query = $this->select('user'); + $reader = new EntityReader($query); + + // Use reflection to call private resetCountCache + $refMethod = new \ReflectionMethod($reader, 'resetCountCache'); + + /** @var void $refMethod->invoke($reader); */ + $refMethod->invoke($reader); + + // Access private countCache property + $refCountCache = new \ReflectionProperty($reader, 'countCache'); + /** @var CachedCount $countCache */ + $countCache = $refCountCache->getValue($reader); + + // Access private query property of countCache + $refCountCacheQuery = new \ReflectionProperty($countCache, 'collection'); + /** @var int $countCacheQuery **/ + $countCacheQuery = $refCountCacheQuery->getValue($countCache); + + $this->assertNotSame($query, $countCacheQuery, 'CachedCount should get a cloned query'); + } + + public function testWithAddedFilterHandlersDoesNotMutateOriginal(): void + { + $reader = new EntityReader($this->select('user')); + $refHandlers = new \ReflectionProperty($reader, 'filterHandlers'); + /** @var array $originalHandlers **/ + $originalHandlers = $refHandlers->getValue($reader); + + $newReader = $reader->withAddedFilterHandlers(new StubFilterHandler()); + /** @var array $newHandlers **/ + $newHandlers = $refHandlers->getValue($newReader); + + // The original reader's handlers should remain unchanged + $this->assertSame($originalHandlers, $refHandlers->getValue($reader)); + // The new reader's handlers should be different + $this->assertNotSame($originalHandlers, $newHandlers); + } + + public function testWithAddedFilterHandlersResetsCountCache(): void + { + $reader = new EntityReader($this->select('user')); + + // Prime the countCache with a dummy object + $refCountCache = new \ReflectionProperty($reader, 'countCache'); + $dummyCache = new CachedCount($this->select('user')); + $refCountCache->setValue($reader, $dummyCache); + + $newReader = $reader->withAddedFilterHandlers(new StubFilterHandler()); + $newReaderCountCache = (new \ReflectionProperty($newReader, 'countCache')); + + // Count cache should be reset (should not be the same object) + $this->assertNotSame( + $dummyCache, + $newReaderCountCache->getValue($newReader), + 'Count cache should be reset in new instance', + ); + } + + public function testReadOneReturnsOnlyOneItem(): void + { + $reader = (new EntityReader($this->select('user')))->withLimit(5); + $result = $reader->readOne(); + $this->assertTrue( + is_array($result) || is_object($result) || $result === null, + 'readOne should return an array, object, or null', + ); + // If it's an array, ensure it matches only the first fixture + if (is_array($result)) { + $this->assertFixtures([0], [$result]); + } + } + + public function testBuildSelectQueryAppliesOffsetCorrectly(): void + { + $reader = new EntityReader($this->select('user')); + $ref = new \ReflectionMethod($reader, 'buildSelectQuery'); + + // Default offset (assumed to be 0) + /** @var Select|SelectQuery */ + $query = $ref->invoke($reader); + // You may need to adjust this depending on your query type + if (method_exists($query, 'getOffset')) { + $this->assertTrue( + $query->getOffset() === null || $query->getOffset() === 0, + 'Default offset should not be set or should be 0', + ); + } + + // Set offset to 2 + $offsetProp = new \ReflectionProperty($reader, 'offset'); + $offsetProp->setValue($reader, 2); + /** @var Select|SelectQuery */ + $queryWithOffset = $ref->invoke($reader); + if (method_exists($queryWithOffset, 'getOffset')) { + $this->assertEquals(2, $queryWithOffset->getOffset(), 'Offset should be set to 2'); + } + } + + public function testReadOneReturnsExactlyOneItemOrNullifFalse(): void + { + $reader = (new EntityReader($this->select('user')))->withLimit(3); + + $item = $reader->readOne(); + + // Should be null, array, or object of stdClass + // class stdClass#4459 (5) { + // public $id => + // int(1) + // public $number => + // int(1) + // public $email => + // string(11) "foo@bar\baz" + // public $balance => + // double(10.25) + // public $born_at => + // NULL + // } + // indicates that readOne() is returning a single database record + // as an object of type stdClass + /** @psalm-suppress RedundantConditionGivenDocblockType */ + $isObject = is_object($item); + + $this->assertTrue( + null === $item || is_array($item) || $isObject, + 'readOne should return null, or array, or object', + ); + + // If it's array, check that it matches only the first fixture (not more than one) + if (is_array($item)) { + $this->assertFixtures([0], [$item]); + } + + // If you want to be extra strict, you can also check that it's not a nested array of arrays + if (is_array($item)) { + $this->assertFalse( + isset($item[0]) && (is_array($item[0]) || is_object($item[0])), + 'readOne should not return a list of multiple items', + ); + } + } + + public function testBuildSelectQueryOffsetBehavior(): void + { + $reader = new EntityReader($this->select('user')); + + $refBuildSelectQuery = new \ReflectionMethod($reader, 'buildSelectQuery'); + + // By default, offset should NOT be set + /** @var SelectQuery */ + $query = $refBuildSelectQuery->invoke($reader); + $this->assertTrue( + $query->getOffset() === null || $query->getOffset() === 0, + 'Offset should not be set by default (should be null or 0)', + ); + + // Set offset to 2, should apply + $offsetProp = new \ReflectionProperty($reader, 'offset'); + $offsetProp->setValue($reader, 2); + /** @var SelectQuery */ + $queryWithOffset = $refBuildSelectQuery->invoke($reader); + $this->assertEquals(2, $queryWithOffset->getOffset(), 'Offset should be set to 2'); + + // Set offset to -1, should NOT apply + $offsetProp->setValue($reader, -1); + /** @var SelectQuery */ + $queryWithOffsetNeg1 = $refBuildSelectQuery->invoke($reader); + $this->assertTrue( + $queryWithOffsetNeg1->getOffset() === null || $queryWithOffsetNeg1->getOffset() === 0, + 'Offset should not be set for -1', + ); + } + + public function testResetCountCacheClonesQuery(): void + { + $query = $this->select('user'); + $reader = new EntityReader($query); + + $refMethod = new \ReflectionMethod($reader, 'resetCountCache'); + + /** @var void $refMethod->invoke($reader); */ + $refMethod->invoke($reader); + + $refCountCache = new \ReflectionProperty($reader, 'countCache'); + /** @var CachedCount $countCache */ + $countCache = $refCountCache->getValue($reader); + + $refCollection = new \ReflectionProperty($countCache, 'collection'); + /** @var int $cachedQuery **/ + $cachedQuery = $refCollection->getValue($countCache); + + $this->assertNotSame($query, $cachedQuery, 'CachedCount should use a cloned query, not the same one'); + } + + public function testWithOffsetZeroBehavesLikeNoOffset(): void + { + $readerNoOffset = new EntityReader($this->select('user')); + $resultsNoOffset = iterator_to_array($readerNoOffset->getIterator()); + + $readerOffsetZero = (new EntityReader($this->select('user')))->withOffset(0); + $resultsOffsetZero = iterator_to_array($readerOffsetZero->getIterator()); + + $this->assertEquals($resultsNoOffset, $resultsOffsetZero, 'Offset of 0 should not change results.'); + } + + public function testReadOneNeverReturnsMultipleRecords(): void + { + $reader = (new EntityReader($this->select('user'))); + $result = $reader->readOne(); + // If your method could ever return a list, this will catch it + $this->assertFalse(is_array($result) && array_is_list($result) && count($result) > 1, 'readOne() must not return more than one record.'); + // If you always return an object or associative array, that's fine. + $this->assertTrue(is_object($result) || is_array($result) || $result === null); + } + + public function testOffsetZeroBehavesAsNoOffset(): void + { + $readerNoOffset = new EntityReader($this->select('user')); + $resultsNoOffset = iterator_to_array($readerNoOffset->getIterator()); + + $readerOffsetZero = (new EntityReader($this->select('user')))->withOffset(0); + $resultsOffsetZero = iterator_to_array($readerOffsetZero->getIterator()); + + $this->assertSame($resultsNoOffset, $resultsOffsetZero, 'Offset of 0 should not change results.'); + } + + public function testOneItemCacheFetchesExactlyOneItem(): void + { + $reader = new EntityReader($this->select('user')); + + // Prime the cache by triggering the fetch + $result = $reader->readOne(); + + // Use reflection to access the private oneItemCache property + $refOneItemCache = new \ReflectionProperty($reader, 'oneItemCache'); + /** @var CachedCollection $oneItemCache */ + $oneItemCache = $refOneItemCache->getValue($reader); + + // Assume oneItemCache has a method getCollection() or similar, adjust if needed + $items = $oneItemCache->getCollection(); + + // Assert only one item is cached, or zero if nothing is found + $this->assertIsArray($items, 'oneItemCache should store collection as array'); + $this->assertLessThanOrEqual(1, count($items), 'oneItemCache must not contain more than one record'); + + // Optionally: check that the cache contains what readOne() returned + if ($result !== null) { + $this->assertContains($result, $items, 'oneItemCache should contain the result of readOne().'); + } + } + + public function testReadOneUsesLimitOne(): void + { + $reader = new EntityReader($this->select('user')); + // Use reflection or a public method to get the SQL used by readOne + $sql = $reader->withLimit(1)->getSql(); + // Note: (1) MySQL and PostgreSQL use the LIMIT clause (e.g., SELECT ... LIMIT 1). + // Note: (2) MSSQL uses a different approach (ROW_NUMBER()/TOP) because it does not support the LIMIT clause. + if ($this->isDriver('mssql')) { + $this->assertStringContainsString('ROW_NUMBER()', $sql); + $this->assertStringContainsString('WHERE [_ROW_NUMBER_] BETWEEN 1 AND 1', $sql); + } else { + $this->assertStringContainsString('LIMIT 1', $sql); + } + } + + public function testReadOneReturnsExactlyOneItem(): void + { + $reader = (new EntityReader($this->select('user')))->withLimit(5); // set up with 3+ items in source + $item = $reader->readOne(); + $this->assertNotNull($item); + // Optionally: Check it's the first item, or has expected ID/fields + + // Assert a second call (with same state) does not return a different item, or returns null if expected + } } diff --git a/tests/Feature/Base/Reader/ReaderWithFilter/BaseReaderWithAllTestCase.php b/tests/Feature/Base/Reader/ReaderWithFilter/BaseReaderWithAllTestCase.php index f9a32bf..fdc5227 100644 --- a/tests/Feature/Base/Reader/ReaderWithFilter/BaseReaderWithAllTestCase.php +++ b/tests/Feature/Base/Reader/ReaderWithFilter/BaseReaderWithAllTestCase.php @@ -4,25 +4,9 @@ namespace Yiisoft\Data\Cycle\Tests\Feature\Base\Reader\ReaderWithFilter; -use Yiisoft\Data\Cycle\Exception\NotSupportedFilterException; -use Yiisoft\Data\Cycle\Reader\EntityReader; use Yiisoft\Data\Cycle\Tests\Feature\DataTrait; -use Yiisoft\Data\Cycle\Tests\Support\NotSupportedFilter; -use Yiisoft\Data\Reader\Filter\All; -use Yiisoft\Data\Reader\Filter\Equals; abstract class BaseReaderWithAllTestCase extends \Yiisoft\Data\Tests\Common\Reader\ReaderWithFilter\BaseReaderWithAllTestCase { use DataTrait; - - public function testNotSupportedFilterException(): void - { - $reader = (new EntityReader($this->select('user'))); - - $this->expectException(NotSupportedFilterException::class); - $this->expectExceptionMessage(sprintf('Filter "%s" is not supported.', NotSupportedFilter::class)); - $reader->withFilter( - new All(new Equals('balance', '100.0'), new NotSupportedFilter(), new Equals('email', 'seed@beat')), - ); - } } diff --git a/tests/Feature/Base/Reader/ReaderWithFilter/BaseReaderWithAndXTestCase.php b/tests/Feature/Base/Reader/ReaderWithFilter/BaseReaderWithAndXTestCase.php new file mode 100644 index 0000000..e872f7b --- /dev/null +++ b/tests/Feature/Base/Reader/ReaderWithFilter/BaseReaderWithAndXTestCase.php @@ -0,0 +1,12 @@ +select('user'))); - - $this->expectException(NotSupportedFilterException::class); - $this->expectExceptionMessage(sprintf('Filter "%s" is not supported.', NotSupportedFilter::class)); - $reader->withFilter(new Any(new Equals('number', 2), new NotSupportedFilter(), new Equals('number', 3))); - } -} diff --git a/tests/Feature/Base/Reader/ReaderWithFilter/BaseReaderWithLikeTestCase.php b/tests/Feature/Base/Reader/ReaderWithFilter/BaseReaderWithLikeTestCase.php index 697c779..25ca3dc 100644 --- a/tests/Feature/Base/Reader/ReaderWithFilter/BaseReaderWithLikeTestCase.php +++ b/tests/Feature/Base/Reader/ReaderWithFilter/BaseReaderWithLikeTestCase.php @@ -4,12 +4,14 @@ namespace Yiisoft\Data\Cycle\Tests\Feature\Base\Reader\ReaderWithFilter; +use PHPUnit\Framework\Attributes\DataProvider; use Yiisoft\Data\Cycle\Tests\Feature\DataTrait; abstract class BaseReaderWithLikeTestCase extends \Yiisoft\Data\Tests\Common\Reader\ReaderWithFilter\BaseReaderWithLikeTestCase { use DataTrait; + #[\Override] public static function dataWithReader(): array { $data = parent::dataWithReader(); @@ -17,4 +19,33 @@ public static function dataWithReader(): array return $data; } + + /** + * Refer to logic code: tests\features\DataTrait e.g. $this->isSqlite() and $this->isSqlServer() + * @param string $field + * @param string $value + * @param bool|null $caseSensitive + * @param array $expectedFixtureIndexes + */ + #[DataProvider('dataWithReader'), \Override] + public function testWithReader(string $field, string $value, ?bool $caseSensitive, array $expectedFixtureIndexes): void + { + // SQLite and SQL Server (MSSQL) are Not case sensitive for the LIKE operator by default. + + // Prevents errors in case-sensitive LIKE on SQLite since case-insensitive for ASCII characters by default + // Example: LIKE 'abc%' matches "abc", "ABC", "Abc", etc. + if ($this->isSqlite() && $caseSensitive === true) { + $this->expectException(\Yiisoft\Data\Cycle\Exception\NotSupportedFilterOptionException::class); + } + + // Prevents errors in case-sensitive LIKE on SqlServer since case-insensitive + // by default, because most SQL Server installations use a case-insensitive collation (e.g., Latin1_General_CI_AS). + // Example: LIKE 'abc%' matches "abc", "ABC", "Abc", etc. + // This is assuming you are not using a case-sensitive collation e.g. Latin1_General_CS_AS + if ($this->isSqlServer() && $caseSensitive === true) { + $this->expectException(\Yiisoft\Data\Cycle\Exception\NotSupportedFilterOptionException::class); + } + + parent::testWithReader($field, $value, $caseSensitive, $expectedFixtureIndexes); + } } diff --git a/tests/Feature/Base/Reader/ReaderWithFilter/BaseReaderWithNoneTestCase.php b/tests/Feature/Base/Reader/ReaderWithFilter/BaseReaderWithNoneTestCase.php new file mode 100644 index 0000000..0e4ef60 --- /dev/null +++ b/tests/Feature/Base/Reader/ReaderWithFilter/BaseReaderWithNoneTestCase.php @@ -0,0 +1,12 @@ +getOrm(); - - $writer = new EntityWriter($this->createEntityManager()); - $writer->write($users = [ - $orm->make('user', ['number' => 99998, 'email' => 'super@test1.com', 'balance' => 1000.0]), - $orm->make('user', ['number' => 99999, 'email' => 'super@test2.com', 'balance' => 999.0]), - ]); - - $reader = new EntityReader( - $this->select('user')->where('number', 'in', [99998, 99999]), - ); - $this->assertEquals($users, $reader->read()); + $entityWriter = $this->createEntityManager(); + if (null !== $entityWriter) { + $writer = new EntityWriter($entityWriter); + $writer->write($users = [ + $orm->make('user', ['number' => 99998, 'email' => 'super@test1.com', 'balance' => 1000.0]), + $orm->make('user', ['number' => 99999, 'email' => 'super@test2.com', 'balance' => 999.0]), + ]); + + $reader = new EntityReader( + $this->select('user')->where('number', 'in', [99998, 99999]), + ); + $this->assertEquals($users, $reader->read()); + } } public function testDelete(): void { - $writer = new EntityWriter($this->createEntityManager()); - $reader = new EntityReader($this->select('user')->where('number', 'in', [1, 2, 3])); - // Iterator doesn't use cache - $entities = \iterator_to_array($reader->getIterator()); + $entityWriter = $this->createEntityManager(); + if (null !== $entityWriter) { + $writer = new EntityWriter($entityWriter); + $reader = new EntityReader($this->select('user')->where('number', 'in', [1, 2, 3])); + $entities = \iterator_to_array($reader->getIterator()); - $writer->delete($entities); + $writer->delete($entities); - $this->assertCount(3, $entities); - $this->assertEquals([], \iterator_to_array($reader->getIterator())); + $this->assertCount(3, $entities); + $this->assertEquals([], \iterator_to_array($reader->getIterator())); + } } } diff --git a/tests/Feature/DataTrait.php b/tests/Feature/DataTrait.php index 2d86eeb..db35a57 100644 --- a/tests/Feature/DataTrait.php +++ b/tests/Feature/DataTrait.php @@ -17,7 +17,6 @@ use Cycle\Database\DatabaseInterface; use Cycle\Database\DatabaseManager; use Cycle\Database\DatabaseProviderInterface; -use Cycle\Database\Driver\Handler; use Cycle\ORM\EntityManager; use Cycle\ORM\EntityManagerInterface; use Cycle\ORM\Factory; @@ -32,12 +31,17 @@ trait DataTrait { - public static $DRIVER = null; + public static string $DRIVER = ''; // cache private ?ORMInterface $orm = null; private ?DatabaseProviderInterface $dbal = null; + protected function isDriver(string $driver): bool + { + return static::$DRIVER === $driver; + } + protected function setUp(): void { $this->dbal ??= $this->createDbal(); @@ -58,7 +62,7 @@ protected function tearDown(): void private function createDbal(): DatabaseProviderInterface { $databases = [ - 'default' => ['connection' => static::$DRIVER ?? 'sqlite'], + 'default' => ['connection' => static::$DRIVER ?: 'sqlite'], 'sqlite' => ['connection' => 'sqlite'], ]; $connections = [ @@ -68,48 +72,72 @@ private function createDbal(): DatabaseProviderInterface ), ]; - if (getenv('CYCLE_MYSQL_DATABASE', local_only: true) !== false) { + if (($database = getenv('CYCLE_MYSQL_DATABASE', local_only: true)) !== false && $database !== '') { $databases['mysql'] = ['connection' => 'mysql']; - $connections['mysql'] = new MySQLDriverConfig( - connection: new MySQLTcpConnectionConfig( - database: getenv('CYCLE_MYSQL_DATABASE'), - host: getenv('CYCLE_MYSQL_HOST'), - port: (int) getenv('CYCLE_MYSQL_PORT'), - user: getenv('CYCLE_MYSQL_USER'), - password: getenv('CYCLE_MYSQL_PASSWORD'), - ), - queryCache: true, - ); + if (($host = getenv('CYCLE_MYSQL_HOST', local_only: true)) !== false && $host !== '') { + if (($port = getenv('CYCLE_MYSQL_PORT', local_only: true)) !== false && $port !== '' && (int) $port > 0 && is_numeric($port)) { + if (($user = getenv('CYCLE_MYSQL_USER', local_only: true)) !== false && $user !== '') { + if (($password = getenv('CYCLE_MYSQL_PASSWORD', local_only: true)) !== false) { + $connections['mysql'] = new MySQLDriverConfig( + connection: new MySQLTcpConnectionConfig( + database: $database, + host: $host, + port: $port, + user: $user, + password: $password === '' ? null : $password, + ), + queryCache: true, + ); + } + } + } + } } - if (getenv('CYCLE_PGSQL_DATABASE', local_only: true) !== false) { + if (($database = getenv('CYCLE_PGSQL_DATABASE', local_only: true)) !== false && $database !== '') { $databases['pgsql'] = ['connection' => 'pgsql']; - $connections['pgsql'] = new PostgresDriverConfig( - connection: new PostgresTcpConnectionConfig( - database: getenv('CYCLE_PGSQL_DATABASE'), - host: getenv('CYCLE_PGSQL_HOST'), - port: (int) getenv('CYCLE_PGSQL_PORT'), - user: getenv('CYCLE_PGSQL_USER'), - password: getenv('CYCLE_PGSQL_PASSWORD'), - ), - schema: 'public', - queryCache: true, - ); + if (($host = getenv('CYCLE_PGSQL_HOST', local_only: true)) !== false && $host !== '') { + if (($port = getenv('CYCLE_PGSQL_PORT', local_only: true)) !== false && $port !== '' && (int) $port > 0 && is_numeric($port)) { + if (($user = getenv('CYCLE_PGSQL_USER', local_only: true)) !== false && $user !== '') { + if (($password = getenv('CYCLE_PGSQL_PASSWORD', local_only: true)) !== false) { + $connections['pgsql'] = new PostgresDriverConfig( + connection: new PostgresTcpConnectionConfig( + database: $database, + host: $host, + port: $port, + user: $user, + password: $password === '' ? null : $password, + ), + schema: 'public', + queryCache: true, + ); + } + } + } + } } - if (getenv('CYCLE_MSSQL_DATABASE', local_only: true) !== false) { + if (($database = getenv('CYCLE_MSSQL_DATABASE', local_only: true)) !== false && $database !== '') { $databases['mssql'] = ['connection' => 'mssql']; - $connections['mssql'] = new SQLServerDriverConfig( - connection: new SQLServerTcpConnectionConfig( - database: getenv('CYCLE_MSSQL_DATABASE'), - host: getenv('CYCLE_MSSQL_HOST'), - port: (int) getenv('CYCLE_MSSQL_PORT'), - trustServerCertificate: true, - user: getenv('CYCLE_MSSQL_USER'), - password: getenv('CYCLE_MSSQL_PASSWORD'), - ), - queryCache: true, - ); + if (($host = getenv('CYCLE_MSSQL_HOST', local_only: true)) !== false && $host !== '') { + if (($port = getenv('CYCLE_MSSQL_PORT', local_only: true)) !== false && $port !== '' && (int) $port > 0 && is_numeric($port)) { + if (($user = getenv('CYCLE_MSSQL_USER', local_only: true)) !== false && $user !== '') { + if (($password = getenv('CYCLE_MSSQL_PASSWORD', local_only: true)) !== false) { + $connections['mssql'] = new SQLServerDriverConfig( + connection: new SQLServerTcpConnectionConfig( + database: $database, + host: $host, + port: $port, + user: $user, + trustServerCertificate: true, + password: $password === '' ? null : $password, + ), + queryCache: true, + ); + } + } + } + } } return new DatabaseManager(new DatabaseConfig(['databases' => $databases, 'connections' => $connections])); @@ -117,17 +145,25 @@ private function createDbal(): DatabaseProviderInterface protected function dropDatabase(): void { + if ($this->dbal === null) { + throw new \RuntimeException('DBAL not initialized'); + } + + /** @var \Cycle\Database\Table $table */ foreach ($this->dbal->database()->getTables() as $table) { + /** @var \Cycle\Database\Schema\AbstractTable $schema */ $schema = $table->getSchema(); foreach ($schema->getForeignKeys() as $foreign) { $schema->dropForeignKey($foreign->getColumns()); } - $schema->save(Handler::DROP_FOREIGN_KEYS); + $schema->save(\Cycle\Database\Driver\Handler::DROP_FOREIGN_KEYS); } + /** @var \Cycle\Database\Table $table */ foreach ($this->dbal->database()->getTables() as $table) { + /** @var \Cycle\Database\Schema\AbstractTable $schema */ $schema = $table->getSchema(); $schema->declareDropped(); $schema->save(); @@ -136,6 +172,7 @@ protected function dropDatabase(): void protected function fillFixtures(): void { + assert($this->dbal !== null); /** @var Database $db */ $db = $this->dbal->database(); if ($db->hasTable('user')) { @@ -147,12 +184,21 @@ protected function fillFixtures(): void $user->column('number')->integer(); $user->column('email')->string()->nullable(false); $user->column('balance')->float()->nullable(false)->defaultValue(0.0); - $user->column('born_at')->date()->nullable(); + $user->column('born_at')->datetime()->nullable(); $user->save(); - $fixtures = static::$fixtures; + /** @var array> $fixtures */ + $fixtures = $this->getFixtures(); + /** @var array $fixture */ foreach ($fixtures as $index => $fixture) { $fixtures[$index]['balance'] = (string) $fixtures[$index]['balance']; + if ( + isset($fixtures[$index]['born_at']) && + $fixtures[$index]['born_at'] instanceof \DateTimeInterface + ) { + // Use a standard format for storing dates as string + $fixtures[$index]['born_at'] = $fixtures[$index]['born_at']->format('Y-m-d H:i:s'); + } } $db @@ -169,16 +215,25 @@ protected function select(string $role): Select protected function getOrm(): ORMInterface { + if ($this->orm === null) { + throw new \RuntimeException('ORM is not initialized'); + } return $this->orm; } private function createOrm(): ORMInterface { + if ($this->dbal === null) { + throw new \RuntimeException('DBAL is not initialized'); + } return new ORM(factory: new Factory($this->dbal), schema: $this->createSchema()); } protected function getDatabase(): DatabaseInterface { + if ($this->dbal === null) { + throw new \RuntimeException('DBAL is not initialized'); + } return $this->dbal->database(); } @@ -211,9 +266,13 @@ private function createSchema(): SchemaInterface ]); } - protected function createEntityManager(): EntityManagerInterface + protected function createEntityManager(): ?EntityManagerInterface { - return new EntityManager($this->orm); + $orm = $this->orm; + if (null !== $orm) { + return new EntityManager($orm); + } + return null; } protected function getReader(): DataReaderInterface @@ -224,23 +283,73 @@ protected function getReader(): DataReaderInterface protected function assertFixtures(array $expectedFixtureIndexes, array $actualFixtures): void { $processedActualFixtures = []; + /** + * @var array $fixture + */ foreach ($actualFixtures as $fixture) { + /** @var array|object $fixture */ if (is_object($fixture)) { - $fixture = json_decode(json_encode($fixture), associative: true); + $json = json_encode($fixture); + if ($json === false) { + throw new \RuntimeException('Failed to JSON-encode fixture'); + } + /** @var array $fixture */ + $fixture = json_decode($json, associative: true); } unset($fixture['id']); $fixture['number'] = (int) $fixture['number']; $fixture['balance'] = (float) $fixture['balance']; + // Ensure born_at is normalized for comparison: + // - null stays null + // - string or object is converted to string 'Y-m-d H:i:s' + if (isset($fixture['born_at']) && $fixture['born_at'] !== null) { + if ($fixture['born_at'] instanceof \DateTimeInterface) { + $fixture['born_at'] = $fixture['born_at']->format('Y-m-d H:i:s'); + } elseif (is_string($fixture['born_at']) && $fixture['born_at'] !== '') { + // Remove milliseconds if present (MSSQL returns .000) + $normalized = preg_replace('/\\.\\d{3}$/', '', $fixture['born_at']); + // Try to parse as date and reformat to standard string (for DB vs object test comparisons) + if ($normalized !== null && $normalized !== '') { + $dt = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $normalized) + ?: \DateTimeImmutable::createFromFormat('Y-m-d', $normalized); + if ($dt !== false) { + $fixture['born_at'] = $dt->format('Y-m-d H:i:s'); + } else { + $fixture['born_at'] = $normalized; + } + } else { + $fixture['born_at'] = $normalized; + } + } + } + $processedActualFixtures[$fixture['number'] - 1] = $fixture; } $expectedFixtures = []; + /** + * @var int $index + */ foreach ($expectedFixtureIndexes as $index) { - $expectedFixtures[$index] = $this->getFixture($index); + $expectedFixture = $this->getFixture($index); + // Normalize born_at for expected fixtures as well + if (isset($expectedFixture['born_at']) && $expectedFixture['born_at'] instanceof \DateTimeInterface) { + $expectedFixture['born_at'] = $expectedFixture['born_at']->format('Y-m-d H:i:s'); + } + $expectedFixtures[$index] = $expectedFixture; } + $this->assertEquals($expectedFixtures, $processedActualFixtures); + } + + protected function isSqlite(): bool + { + return $this->isDriver('sqlite'); + } - $this->assertSame($expectedFixtures, $processedActualFixtures); + protected function isSqlServer(): bool + { + return $this->isDriver('mssql'); } } diff --git a/tests/Feature/Mssql/Reader/EntityReaderTest.php b/tests/Feature/Mssql/Reader/EntityReaderTest.php index ad466aa..75534e9 100644 --- a/tests/Feature/Mssql/Reader/EntityReaderTest.php +++ b/tests/Feature/Mssql/Reader/EntityReaderTest.php @@ -8,8 +8,9 @@ final class EntityReaderTest extends BaseEntityReaderTestCase { - public static $DRIVER = 'mssql'; + public static string $DRIVER = 'mssql'; + #[\Override] public static function dataGetSql(): array { return [ diff --git a/tests/Feature/Mssql/Reader/ReaderWithFilter/ReaderWithAllTest.php b/tests/Feature/Mssql/Reader/ReaderWithFilter/ReaderWithAllTest.php index 59a912d..a4fcd8f 100644 --- a/tests/Feature/Mssql/Reader/ReaderWithFilter/ReaderWithAllTest.php +++ b/tests/Feature/Mssql/Reader/ReaderWithFilter/ReaderWithAllTest.php @@ -8,5 +8,5 @@ final class ReaderWithAllTest extends BaseReaderWithAllTestCase { - public static $DRIVER = 'mssql'; + public static string $DRIVER = 'mssql'; } diff --git a/tests/Feature/Mssql/Reader/ReaderWithFilter/ReaderWithAndXTest.php b/tests/Feature/Mssql/Reader/ReaderWithFilter/ReaderWithAndXTest.php new file mode 100644 index 0000000..cd62c3e --- /dev/null +++ b/tests/Feature/Mssql/Reader/ReaderWithFilter/ReaderWithAndXTest.php @@ -0,0 +1,12 @@ + [ + <<assertSame(2, $cached->getCount()); // must return cached value and not call count() again + /** @psalm-suppress InternalMethod */ $this->assertSame(2, $cached->getCount()); } } diff --git a/tests/Unit/Reader/EntityReaderTest.php b/tests/Unit/Reader/EntityReaderTest.php index 10d0238..0cb60d6 100644 --- a/tests/Unit/Reader/EntityReaderTest.php +++ b/tests/Unit/Reader/EntityReaderTest.php @@ -16,11 +16,9 @@ public function testNormalizeSortingCriteria(): void $reader = new EntityReader($this->createMock(SelectQuery::class)); $ref = new \ReflectionMethod($reader, 'normalizeSortingCriteria'); - $ref->setAccessible(true); - $this->assertSame( ['number' => 'ASC', 'name' => 'DESC', 'email' => 'ASC'], - $ref->invoke($reader, ['number' => 'ASC', 'name' => SORT_DESC, 'email' => SORT_ASC]) + $ref->invoke($reader, ['number' => 'ASC', 'name' => SORT_DESC, 'email' => SORT_ASC]), ); } diff --git a/tests/Unit/Sqlite/Reader/FilterHandler/SqliteLikeHandlerTest.php b/tests/Unit/Sqlite/Reader/FilterHandler/SqliteLikeHandlerTest.php index d32348f..0f149bc 100644 --- a/tests/Unit/Sqlite/Reader/FilterHandler/SqliteLikeHandlerTest.php +++ b/tests/Unit/Sqlite/Reader/FilterHandler/SqliteLikeHandlerTest.php @@ -11,7 +11,7 @@ final class SqliteLikeHandlerTest extends TestCase { - public static $DRIVER = 'sqlite'; + public static string $DRIVER = 'sqlite'; public function testNotSupportedFilterOptionException(): void {