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 @@
[](https://packagist.org/packages/yiisoft/data-cycle)
[](https://packagist.org/packages/yiisoft/data-cycle)
[](https://codecov.io/gh/yiisoft/data-cycle)
-[](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/data-cycle/master)
+[](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/data-cycle/master)
[](https://github.com/yiisoft/data-cycle/actions?query=workflow%3A%22static+analysis%22)
[](https://shepherd.dev/github/yiisoft/data-cycle)
[](https://shepherd.dev/github/yiisoft/data-cycle)
+[](https://packagist.org/packages/yiisoft/data-cycle)
+[](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
{