diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..f1fe42f
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,5 @@
+/.github export-ignore
+/tests export-ignore
+/.gitattributes export-ignore
+/.gitignore export-ignore
+/phpunit.xml.dist export-ignore
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..b0ad3c3
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,148 @@
+name: Tests
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ tests:
+ name: PHP ${{ matrix.php }} / ${{ matrix.dependencies }} dependencies
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.4', '8.5']
+ dependencies: ['highest']
+ include:
+ - php: '8.4'
+ dependencies: 'lowest'
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: pdo, pdo_sqlite, sqlite3, mbstring, tokenizer
+ coverage: none
+ ini-values: error_reporting=-1, display_errors=On, zend.assertions=1
+
+ - name: Install dependencies
+ uses: ramsey/composer-install@v3
+ with:
+ dependency-versions: ${{ matrix.dependencies }}
+
+ - name: Run tests
+ run: vendor/bin/phpunit
+
+ tests-mysql:
+ name: PHP ${{ matrix.php }} / MySQL ${{ matrix.mysql }}
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - php: '8.4'
+ mysql: '8.0'
+ - php: '8.5'
+ mysql: '8.4'
+
+ services:
+ mysql:
+ image: mysql:${{ matrix.mysql }}
+ env:
+ MYSQL_ROOT_PASSWORD: root
+ MYSQL_DATABASE: doctrine_components_test
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd="mysqladmin ping -uroot -proot"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=10
+
+ env:
+ DB_DRIVER: pdo_mysql
+ DB_HOST: 127.0.0.1
+ DB_PORT: 3306
+ DB_USER: root
+ DB_PASSWORD: root
+ DB_NAME: doctrine_components_test
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: pdo, pdo_mysql, pdo_sqlite, sqlite3, mbstring, tokenizer
+ coverage: none
+ ini-values: error_reporting=-1, display_errors=On, zend.assertions=1
+
+ - name: Install dependencies
+ uses: ramsey/composer-install@v3
+ with:
+ dependency-versions: highest
+
+ - name: Run tests
+ run: vendor/bin/phpunit
+
+ coverage:
+ name: Coverage
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.4'
+ extensions: pdo, pdo_sqlite, sqlite3, mbstring, tokenizer
+ coverage: xdebug
+ ini-values: error_reporting=-1, display_errors=On
+
+ - name: Install dependencies
+ uses: ramsey/composer-install@v3
+ with:
+ dependency-versions: highest
+
+ - name: Run tests with coverage
+ run: vendor/bin/phpunit --coverage-text --coverage-clover=coverage.xml
+
+ - name: Upload coverage artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage
+ path: coverage.xml
+ if-no-files-found: error
+
+ composer-validate:
+ name: Composer validate
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.4'
+ coverage: none
+
+ - name: Validate composer.json
+ run: composer validate --strict --no-check-publish
diff --git a/.gitignore b/.gitignore
index d3137d5..cee39e2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,9 @@
/vendor
/composer.lock
-.idea
\ No newline at end of file
+.idea
+
+/.phpunit.cache
+/phpunit.xml
+/coverage.xml
+/coverage
diff --git a/README.md b/README.md
index dfdb4e4..3af0c5e 100644
--- a/README.md
+++ b/README.md
@@ -96,6 +96,10 @@ Method `by` is a shortcut for creating `filter` callbacks. It offers some useful
- When there are more columns, `orWhere` is used among them.
+- Pass `$filterKey` as the fourth argument to register the filter under a name, so that it can be
+ turned off later with `disableFilter()`. Calling `by()` again with the same key replaces the filter
+ instead of adding a second condition.
+
- If a `$value` is type of 'string', `LIKE %$value%` is used. You can change it by parameter `filterType` with value `FilterTypeEnum::STRICT`.
- If you would like get all value in certain range, you can use parameter `filterType` with value `FilterTypeEnum::RANGE`.
@@ -247,6 +251,10 @@ public function byShowOnWeb(): static
Unlike `QueryBuilder::innerJoin` and `QueryBuilder::leftJoin`, this ensures that same joins are not used multiple times and don't throw an error.
+Joins are deduplicated **by alias only**, the first join registered for an alias wins. A subclass can
+use that to re-point an alias: register the join before calling `parent::init()` and every inherited
+join and condition using that alias will refer to your relation instead.
+
### More columns
Don't use `addSelect` inside a `filter` callback. Use `initSelect` method instead:
@@ -357,6 +365,39 @@ foreach ($profiles as $_profile) {
You should always use new `EntityManager` instance, not the default one (because of `EntityManager::clear`).
+## Tests
+
+```
+composer install
+composer tests
+```
+
+By default the tests run against an in-memory SQLite database, so no external service is needed. The
+test entities and query objects used by the suite live in `tests/Fixtures`.
+
+To run the same suite against MySQL, set the connection via environment variables:
+
+```
+DB_DRIVER=pdo_mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=root DB_PASSWORD=root \
+DB_NAME=doctrine_components_test vendor/bin/phpunit
+```
+
+The MySQL database is created once per process and emptied before every entity manager is handed out,
+so each test still starts from an empty database with auto increment reset. A handful of tests are
+driver specific (named `...OnMysql` / `...OnSqlite`) and skip themselves on the other driver.
+
+Because the suite drops the schema and truncates tables, two runs must never share one MySQL database
+at the same time - give each parallel run its own `DB_NAME`. In CI every job gets its own service
+container, so nothing is shared.
+
+CI (`.github/workflows/tests.yml`) runs the suite on PHP 8.4 and 8.5 against SQLite, on PHP 8.4/MySQL 8.0
+and PHP 8.5/MySQL 8.4, plus one job with the lowest allowed dependency versions.
+
+`phpunit.xml.dist` is strict: risky tests, warnings, notices and deprecations coming from `src`
+fail the build. There is no baseline, `src` is expected to stay free of deprecations.
+
+`docs/fixes.md` documents the bugs the test suite uncovered and how they were fixed.
+
## Tips
- Always have all logic inside a `filter` or `order` callback. This will ensure that all dependencies (like a logged user etc.) are already set.
diff --git a/composer.json b/composer.json
index 2d74df1..62c0464 100644
--- a/composer.json
+++ b/composer.json
@@ -18,15 +18,32 @@
},
"require": {
"php": ">=8.4",
- "doctrine/orm": "^2.18|^3.0",
+ "doctrine/orm": "^3.3",
+ "doctrine/dbal": "^4.0",
"nette/utils": "^3.2|^4.0",
"tracy/tracy": "^2.10",
"psr/log": "^3.0",
"nettrine/dbal": "^0.9.0 | ^0.10.0"
},
+ "require-dev": {
+ "phpunit/phpunit": "^11.5 | ^12.0",
+ "symfony/cache": "^7.0",
+ "nette/di": "^3.1 | ^4.0"
+ },
"autoload": {
"psr-4": {
"ADT\\DoctrineComponents\\": "src/"
}
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "ADT\\DoctrineComponents\\Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "tests": "phpunit"
+ },
+ "config": {
+ "sort-packages": true
}
}
diff --git a/docs/fixes.md b/docs/fixes.md
new file mode 100644
index 0000000..65d8dc3
--- /dev/null
+++ b/docs/fixes.md
@@ -0,0 +1,275 @@
+# Opravy nalezené při psaní testovací sady
+
+Přehled všeho, co se opravovalo, proč, a co se naopak vědomě nechalo. Detail k `postFetch` je
+zvlášť v [`postfetch-fix.md`](postfetch-fix.md).
+
+Všechno našly testy v `tests/`. Každá oprava je ověřená na PHP 8.4 i 8.5, na SQLite i MySQL 8.0,
+s nejnovějšími i nejnižšími povolenými závislostmi, a navíc nasazením do reálného projektu.
+
+## Souhrn
+
+| # | co | soubor | druh změny |
+|---|---|---|---|
+| 1 | `postFetch` byl od v3.2 mrtvý kód | `QueryObject.php` | oprava chyby |
+| 2 | `fetchOne()` volal postFetch dvakrát | `QueryObject.php` | oprava chyby |
+| 3 | `null` jako klíč pole v `byId()` a `fetchField()` | `QueryObject.php` | PHP 8.5 deprecation |
+| 4 | `setAccessible()` v `mapToDTO()` | `QueryObject.php` | PHP 8.5 deprecation |
+| 5 | `validateFieldNames()` v `by()` nekontroloval nic | `QueryObject.php` | oprava chyby, **mění chování** |
+| 6 | `orById()` se zahodil při kombinaci s `byId()` | `QueryObject.php` | oprava chyby, **mění chování** |
+| 7 | vlastní `entityAlias` rozbil `byId()`, `orById()`, `fetchField()` | `QueryObject.php` | oprava chyby |
+| 8 | `fetchPairs($value, null)` hodil nejasný `Error` | `QueryObject.php` | jasná chybová hláška |
+| 9 | `byIsActive()` a `disableIsActiveFilter()` spolu nefungovaly | `IsActiveFilterTrait.php` + `by()` | oprava chyby, **rozšiřuje API** |
+| 10 | `require` povoloval DBAL 3, se kterým `src/Logging/*` nejde načíst | `composer.json` | **zužuje podporu** |
+
+Tři změny mění chování a jedna zužuje podporované verze. Detaily a odůvodnění níž.
+
+---
+
+## 1 a 2. postFetch
+
+Popsané zvlášť v [`postfetch-fix.md`](postfetch-fix.md), včetně toho, proč si toho roky nikdo nevšiml
+(nikdo z projektů `addPostFetch()` nevolá a symptomem není špatný výsledek, jen víc dotazů).
+
+Ve zkratce: přejmenování `IEntity` → `Entities\Entity` v commitu `a337461` neaktualizovalo referenci
+v `QueryObject.php`, `instanceof` na nedeklarovanou třídu vrací `false` bez chyby, takže
+`doPostFetch()` vždycky skončil na prvním `return`. Za tím se skrývaly další tři chyby
+(`ClassMetadataInfo`, `mappedBy` přes `ArrayAccess`, `PARTIAL`), které se projevily až po odemčení kódu.
+
+## 3. `null` jako klíč pole
+
+Na PHP 8.5 je `$array[null]` deprecated. Objevovalo se to na dvou místech: `byId()` s entitou bez ID
+nebo s `null` v poli, a `fetchField()` nad nullable sloupcem.
+
+`byId()` je teď přepsaný tak, aby `null` přeskočil, ale **filtr se pořád aplikuje**. To je podstatné:
+
+```php
+$this->byIdFilter ??= []; // filtr je "zapnutý" i když je pole prázdné
+if (($_id = $this->resolveId($item)) !== null) {
+ $this->byIdFilter[$_id] = $_id;
+}
+```
+
+`byIdFilter` je `?array` a `null` znamená „filtr se neaplikuje", `[]` znamená „aplikuje se
+a nic nematchne" (`id IN (NULL)`). Kdyby se `null` jen přeskočilo bez toho `??= []`, tak by
+`byId(new Author())` (entita bez ID) přestala filtrovat a vrátila **všechno** místo ničeho.
+To by byla bezpečnostní regrese, takže na to je test.
+
+Efektivní SQL výsledek je proti stavu před opravou identický, protože `NULL` v `IN (...)` nikdy
+nematchne. Změnil se jen obsah parametru: `byId([1, null])` posílá `[1]` místo `[1, null]`.
+
+`byId()` navíc už nepoužívá `count($id)` na iterable, takže funguje i s generátorem (dřív by
+`count()` na `Generator` hodilo `TypeError`).
+
+`fetchField()` má jen doplněný `?? ''`, takže klíč pro `NULL` hodnotu je `''` stejně jako dřív,
+jen bez deprecation.
+
+## 4. `setAccessible()`
+
+Nemá od PHP 8.1 žádný efekt a v 8.5 je deprecated. Package vyžaduje `php: >=8.4`, takže odstranění
+je bezpečné bez jakékoli podmínky. Odstraněné ze `mapToDTO()` a ze `doPostFetch()`.
+
+## 5. `validateFieldNames()` nekontroloval nic
+
+```php
+// před
+foreach ($fields as $_name => $_order) {
+ if (explode('.', $_name)[0] === $this->entityAlias) { throw ... }
+}
+```
+
+Iterovalo se přes **klíče**. U `orderBy(['name' => 'ASC'])` jsou klíče jména sloupců, takže tam to
+fungovalo. U `by(['name', 'email'])` jsou to ale číselné indexy, takže kontrola nikdy nic nenašla.
+
+`by('e.name', 'x')` proto neskončil hláškou „Do not use entity alias in field names", ale vygeneroval
+nesmyslné `LEFT JOIN e.e e` a spadl až na `QueryException` z Doctriny.
+
+Teď se iteruje přes hodnoty a `orderBy()` posílá `array_keys($field)`.
+
+**Mění chování:** `by('e.cokoli')` teď hodí `Exception` s jasnou hláškou místo `QueryException`.
+Zkontroloval jsem všech 7 lokálních projektů, `by()` s prefixem entity aliasu nikdo nepoužívá.
+
+## 6. `orById()` se zahodil při kombinaci s `byId()`
+
+Ty dva bloky byly ve špatném pořadí:
+
+```php
+// před: orById se testoval na existenci WHERE dřív, než ho byId přidalo
+if ($this->orByIdFilter && $qb->getDQLPart('where')) { $qb->orWhere(...); }
+if ($this->byIdFilter !== null) { $qb->andWhere(...); }
+```
+
+`byId(1)->orById(3)` tedy vrátilo jen záznam 1. Po prohození vrací 1 i 3.
+
+Podmínka `$qb->getDQLPart('where')` **zůstává**, takže `orById(3)` jako jediný filtr je pořád no-op.
+To je záměr a je to logicky správně: dotaz bez podmínek už vrací všechno, takže „všechno NEBO id 3"
+je zase všechno. Je na to test, aby to někdo omylem „neopravil".
+
+## 7. Vlastní `entityAlias`
+
+`byId()`, `orById()` a `fetchField()` měly alias `'e'` napevno, takže query object s přepsaným
+`$entityAlias` generoval nevalidní DQL (`SELECT a FROM Author a WHERE e.id IN (...)`). Nahrazeno
+za `$this->entityAlias`.
+
+`doPostFetch()` si staví vlastní query buildery a `'e'` v nich je jeho vlastní alias, ten zůstává.
+
+## 8. `fetchPairs($value, null)`
+
+Hodilo to `Error: Call to undefined method ...::get()`, protože se skládal getter z prázdného jména.
+Teď je na začátku jasná kontrola:
+
+```php
+if ($key === null) {
+ throw new Exception('Parameter "$key" is required, there is nothing to key the result by.');
+}
+```
+
+Signatura `?string $key` zůstává, protože ji předepisuje `QueryObjectInterface`. Kdybyste chtěli,
+aby `null` znamenalo „vrať seznam bez klíčů" (jako Nette Database), je to funkční rozhodnutí, ne
+oprava chyby, takže jsem to nedělal.
+
+## 9. `byIsActive()` a `disableIsActiveFilter()`
+
+`byIsActive()` používal `by()`, které filtr registruje pod **číselný** klíč, zatímco
+`disableIsActiveFilter()` mazal klíč `'isActiveFilter'`. Nikdy se tedy netrefily.
+
+`by()` má proto nově čtvrtý nepovinný parametr:
+
+```php
+public function by(array|string $column, mixed $value = null, QueryObjectByMode $mode = QueryObjectByMode::AUTO, ?string $filterKey = null): static
+```
+
+S `$filterKey` se filtr registruje pod tímto klíčem a jde ho vypnout přes `disableFilter()`.
+Bez něj se chová přesně jako dřív. Trait to používá:
+
+```php
+return $this->by('isActive', $isActive, QueryObjectByMode::AUTO, IsActiveFilter::IS_ACTIVE_FILTER);
+```
+
+**Rozšiřuje API:** parametr je přidaný i do `QueryObjectInterface`. Přidání nepovinného parametru
+do rozhraní je formálně BC break pro cizí implementace toho rozhraní. `QueryObject` je jediná
+implementace ve všech projektech, takže reálně to nikoho nezasáhne.
+
+**Vedlejší efekt:** opakované `byIsActive()` už nestohuje podmínky, druhé volání to první přepíše
+(protože jde o stejný klíč). To je žádoucí, dřív `byIsActive(false)` po defaultním `byIsActive(true)`
+vytvořilo `isActive = true AND isActive = false`, což nikdy nic nevrátilo.
+
+## 10. `require` povoloval DBAL 3
+
+```diff
+-"doctrine/orm": "^2.18|^3.0",
++"doctrine/orm": "^3.3",
++"doctrine/dbal": "^4.0",
+```
+
+`src/Logging/*` má signatury DBAL 4 (`bindValue(..., ParameterType $type): void`,
+`beginTransaction(): void`). ORM 2 i ORM 3.0–3.2 přitom táhnou DBAL 3, a s ním se ty třídy ani
+nenačtou:
+
+```
+Fatal error: Declaration of ADT\DoctrineComponents\Logging\Statement::bindValue(...)
+must be compatible with AbstractStatementMiddleware::bindValue($param, $value, $type = ...)
+```
+
+Projevilo by se to při prvním zapnutém Tracy panelu. `^3.3` je ověřeně nejnižší ORM, které DBAL 4
+připouští (`--prefer-lowest` s tímto `require` vyřeší ORM 3.3.0 + DBAL 4.2.1).
+
+**Zužuje podporu:** ORM 2 už není podporované. Chce to bump minor verze a poznámku do changelogu.
+Všech 7 lokálních projektů už na ORM 3.6 a DBAL 4 běží, takže je to nezasáhne.
+
+---
+
+## Co se vědomě NEopravilo
+
+### Deduplikace joinů podle aliasu
+
+Původně jsem to měl za chybu: `getJoinFilterKey()` ignoroval všechny parametry kromě aliasu, takže
+druhý join se stejným aliasem se tiše zahodil. Napsal jsem opravu, která na konflikt hodí výjimku.
+
+**Pak jsem to vrátil**, protože jsem si to ověřil na `sobit-pokladna-api` a je to load-bearing:
+
+```php
+class ReportSettlementOrganizerGridQuery extends OrderItemQuery
+{
+ public function getEntityClass(): string { $this->entityAlias = 'e'; return Account::class; }
+
+ public function init(): void
+ {
+ $this->filter[] = function (QueryBuilder $qb): void {
+ $this->leftJoin($qb, 'e.orders', '_order'); // <- zaregistruje alias PRVNÍ
+ ...
+ };
+ parent::init();
+ }
+}
+```
+
+Rodič `OrderItemQuery` má šest `by*()` metod, každá dělá `innerJoin($qb, 'e.order', '_order')`
+a pak se odkazuje na `_order.branch`, `_order.date` a podobně. Ty joiny se dnes díky deduplikaci
+podle aliasu **tiše přeskočí** a `_order` zůstane ukazovat na `Account.orders`. Potomek tím vědomě
+přesměrovává všechny dědené joiny a podmínky na jinou relaci.
+
+Výjimka na konfliktu by tyhle grid dotazy položila. Chování je tedy schválně zachované a v kódu
+je u `commonJoin()` komentář, aby to někdo „neopravil" znovu. Testy v `JoinTest` ten override
+mechanismus popisují, aby byl vidět jako záměr, a je zdokumentovaný i v README.
+
+Statická kontrola všech 7 projektů (skript hledá alias použitý pro dvě různé relace v jedné třídě
+včetně rodičů) našla tenhle vzor ve 4 třídách `sobit-pokladna-api`; v ostatních projektech jsou
+duplicitní aliasy vždy v různých třídách, kde se nepotkají.
+
+### Statický stav v `BaseListener`
+
+`private static int $transactionsStartedCount` a `private static bool $possibleChangesChecked` jsou
+sdílené mezi všemi instancemi listenerů. Vypadá to jako smell, ale je to konzistentní se svým účelem:
+transakce je jedna na spojení a flush cyklus je jeden, takže několik listenerů se má koordinovat.
+Předělání na instanční stav by tu koordinaci rozbilo. Nechávám a testy současné chování popisují.
+
+### Zakomentované bloky kontrol
+
+V `createQueryBuilder()` (`$forbiddenDQLParts`) a ve `fetch()` (kontrola `hasModifiedColumns`) jsou
+zaparkované zakomentované kontroly. Nejsou to chyby a smazat cizí vědomě odložený kód bez zeptání
+mi nepřišlo správné.
+
+### `count()` a duplikáty z joinů
+
+`count()` používá `COUNT(e.id)` bez `DISTINCT`, takže join na `*_TO_MANY` nafoukne výsledek.
+Není to chyba, `getCountExpr()` je dokumentovaný extension point pro `COUNT(DISTINCT e.id)`.
+Testy obojí chování pokrývají.
+
+---
+
+## Ověření
+
+**Vlastní sada:** 452 testů, zeleně ve všech kombinacích:
+
+| | PHP 8.4 | PHP 8.5 |
+|---|---|---|
+| SQLite, nejnovější závislosti | ✔ | ✔ |
+| MySQL 8.0, nejnovější závislosti | ✔ | ✔ |
+| SQLite, `--prefer-lowest` (ORM 3.3.0 / DBAL 4.2.1) | ✔ | ✔ |
+| MySQL 8.0, `--prefer-lowest` | ✔ | ✔ |
+
+Pokrytí `src`: 98 % řádků, 98 % metod.
+
+CI je nastavená striktně (`failOnRisky`, `failOnWarning`, `failOnNotice`, `failOnDeprecation`)
+**bez baseline** — po opravách už ve `src/` žádná deprecation nezbyla. Jediná výjimka je
+`ignoreIndirectDeprecations`, která odfiltruje deprecations pocházející z `vendor/`.
+
+**Reálný projekt (prevozpenez, ORM 3.6.7 / DBAL 4.4.3, PHP 8.5):** opravený `src/` nasazený do
+`vendor/` projektu, pak
+
+- PHPStan level 3 nad `app` + `tests`: **19 chyb před i po, diff prázdný** (všechny pre-existující)
+- Codeception Unit suite: **OK (309 testů, 1535 asercí)**
+
+Vendor projektu potom vrácený do původního stavu, ověřeno `diff -r`.
+
+**Statická kontrola rizikových změn napříč všemi 7 projekty:**
+
+- `by()` s prefixem entity aliasu (bod 5): 0 výskytů
+- alias použitý pro dvě různé relace v jedné query třídě: viz sekce o deduplikaci joinů
+
+## Co je pořád otevřené
+
+- Test suite pokrývá jen SQLite a MySQL, ne PostgreSQL ani MariaDB.
+- Neběží mutation testing, takže 98 % pokrytí `QueryObject` znamená „řádky se vykonaly", ne že
+ jsou asertace úplné.
+- Nad samotným packagem neběží statická analýza. Přidání PHPStanu do CI by byl logický další krok.
diff --git a/docs/postfetch-fix.md b/docs/postfetch-fix.md
new file mode 100644
index 0000000..be1f031
--- /dev/null
+++ b/docs/postfetch-fix.md
@@ -0,0 +1,215 @@
+# Oprava postFetch (doPostFetch)
+
+Detail k tomu, proč byl `postFetch` od verze **v3.2** nefunkční a co bylo potřeba opravit.
+Souhrn všech oprav z tohoto kola je v [`fixes.md`](fixes.md).
+
+## TL;DR
+
+`QueryObject::doPostFetch()` od v3.2 vždycky skončil na prvním `return` a nikdy nic nepředfetchoval.
+Bylo to **tiché** — nešlo o špatné výsledky, jen o víc dotazů, takže se to neprojevilo jako bug.
+Odemčení kódu pak odhalilo další tři chyby, které se nikdy nemohly projevit.
+
+## Proč to bylo rozbité
+
+Commit `a337461` (27. 9. 2025, „Adds base entity with identifier trait") přejmenoval rozhraní:
+
+```
+src/IEntity.php -> src/Entities/Entity.php
+interface IEntity -> interface Entity
+```
+
+Ten commit sáhl na tři soubory a `QueryObject.php` mezi nimi nebyl. Zůstala v něm tedy viset
+reference na starý název — v `use` na řádku 5 a hlavně ve výkonném kódu:
+
+```php
+if (!is_object($firstRootEntity) || !($firstRootEntity instanceof IEntity)) {
+ return;
+}
+```
+
+Klíčové je, že **`instanceof` na nedeklarovanou třídu vrátí `false` a nic nenahlásí** — ani nespustí
+autoloader, ani nezapíše nic do `error_get_last()`. Proto se to neprojevilo ani při `error_reporting=-1`.
+Od té doby každé volání `doPostFetch()` skončilo o dva řádky dál.
+
+### Kdy se to dostalo do release
+
+| verze | `src/IEntity.php` | stav |
+|---|---|---|
+| do v3.1 | existuje | postFetch funguje |
+| **v3.2 a novější** | neexistuje | **postFetch nedělá nic** |
+
+## Proč si to nikdo nevšiml
+
+Dva důvody a oba jsou podstatné:
+
+1. **Není to chyba správnosti, ale výkonu.** Když prefetch neproběhne, Doctrine kolekce dolazy
+ normálně lazy-loadem. Výsledky jsou identické, jen se udělá víc dotazů. Nikdo tedy nemá důvod
+ podat bug report.
+
+2. **Nikdo tu metodu nevolá.** Kontrola všech lokálních projektů:
+
+ | projekt | verze packagu | `addPostFetch` v kódu projektu |
+ |---|---|---|
+ | agelplus | v3.2.6 | 0 |
+ | paydroid-web | v3.3.2 | 0 |
+ | prevozpenez | v3.3.3 | 0 |
+ | sandbox_web | v3.3.3 | 0 |
+ | sobitecr | v3.3.2 | 0 |
+ | sobit-pokladna-api | v3.3.4 | 0 |
+ | tms-new | v3.3.2 | 0 |
+
+ Ani žádný jiný `adt/*` package ho nevolá. Projekty přitom `QueryObject` aktivně dědí, jen
+ používají jiné části.
+
+## Co bylo opravené
+
+Všechno v `src/QueryObject/QueryObject.php`. Pořadí není náhodné — každá další chyba se objevila až
+po opravě té předchozí, protože kód za `return`em byl kompletně nedosažitelný a nikdy se nespustil.
+
+### 1. Špatná reference na rozhraní
+
+```diff
+-use ADT\DoctrineComponents\IEntity;
++use ADT\DoctrineComponents\Entities\Entity;
+
+-if (!is_object($firstRootEntity) || !($firstRootEntity instanceof IEntity)) {
++if (!is_object($firstRootEntity) || !($firstRootEntity instanceof Entity)) {
+```
+
+Plus tři docbloky. Tohle je ta vlastní příčina.
+
+### 2. `ClassMetadataInfo` v ORM 3 neexistuje (7 výskytů)
+
+Po opravě bodu 1 kód poprvé došel dál a spadl:
+
+```
+Fatal error: Class "Doctrine\ORM\Mapping\ClassMetadataInfo" not found
+```
+
+`ClassMetadataInfo` byla v ORM 3 odstraněná. Konstanty `TO_ONE`, `TO_MANY`, `ONE_TO_MANY`
+a `MANY_TO_MANY` jsou dostupné na `ClassMetadata`, a to i v ORM 2 (dědí je), takže náhrada je
+kompatibilní s oběma:
+
+```diff
+-Doctrine\ORM\Mapping\ClassMetadataInfo::TO_ONE
++Doctrine\ORM\Mapping\ClassMetadata::TO_ONE
+```
+
+### 3. `mappedBy` na owning side MANY_TO_MANY v ORM 3 hodí výjimku
+
+```
+OutOfRangeException: Unknown property "mappedBy" on class ManyToManyOwningSideMapping
+```
+
+V ORM 2 bylo `$association` obyčejné pole, takže `$association['mappedBy']` vrátilo na owning side
+`null` a `?:` propadlo na `inversedBy`. V ORM 3 je to objekt `AssociationMapping` s `ArrayAccess`,
+který na nedeklarovanou property **hodí výjimku** místo `null`:
+
+```diff
+-$propertyName = $association['mappedBy'] ?: $association['inversedBy'];
++$propertyName = ($association['mappedBy'] ?? null) ?: ($association['inversedBy'] ?? null);
+```
+
+Operátor `??` volá nejdřív `offsetExists()`, takže k výjimce nedojde. Na poli (ORM 2) funguje stejně.
+Tohle je zrádné, protože se to projeví jen u MANY_TO_MANY z owning side — ONE_TO_MANY prošlo.
+
+### 4. `PARTIAL` byl v ORM 3.0 odstraněný
+
+```
+[Syntax Error] line 0, col 16: Error: Expected T_FROM, got '.'
+```
+
+Řádek se selectem IDček TO_ONE asociací používal `PARTIAL`, který ORM 3.0 vůbec nezná (parser ho
+nemá). Alias `e_id` se přitom nikde nečte — dál se pracuje jen s `$row['id_' . $i]` z `addSelect`.
+Stačí tedy obyčejný select:
+
+```diff
+-->select('PARTIAL e.{id} AS e_id')
++->select('e.id')
+```
+
+Tohle je zároveň jediná změna, která by teoreticky mohla ovlivnit tvar výsledku, proto:
+`getScalarResult()` vrací pro `e.id` klíč `id`, který se nepoužívá, a `IDENTITY(...) AS id_N`
+aliasy zůstávají nedotčené.
+
+### 5. `setAccessible()` je od PHP 8.5 deprecated (3 výskyty)
+
+`ReflectionProperty::setAccessible()` nemá od PHP 8.1 žádný efekt a v 8.5 je deprecated. V `doPostFetch`
+to dosud nevadilo (kód byl mrtvý), po opravě by to začalo hlásit deprecation. Package vyžaduje
+`php: >=8.4`, takže odstranění je bezpečné bez podmínek.
+
+### 6. `fetchOne()` volal postFetch dvakrát
+
+Souvisejicí, ale nezávislá chyba. `fetchOne()` volá `fetch()`, které postFetch spustí samo, a pak ho
+volal ještě jednou:
+
+```diff
+ if ($strict && count($result) > 1) {
+ throw new NonUniqueResultException();
+ }
+-
+-$this->postFetch(new ArrayIterator($result));
+
+ return $result[0];
+```
+
+Změřeno přepsáním `doPostFetch()` v potomkovi (není `final`, `postFetch()` ho volá přes `static::`):
+
+| | před opravou | po opravě |
+|---|---|---|
+| `fetch()` | 1× | 1× |
+| `fetchOne()` | 2× | 1× |
+| `fetchOneOrNull()` | 2× | 1× |
+
+Dokud byl postFetch mrtvý, nic to nestálo. Bez téhle opravy by ale po opravě bodů 1–4 každé
+`fetchOne()` prohnalo prefetch dvakrát, tedy dvojnásobek dotazů — projevilo by se to jako
+výkonnostní regrese až po nasazení.
+
+## Že to funguje
+
+Prefetch teď reálně šetří dotazy (měřeno přes `SqlLogger`, 5 autorů ve fixtures):
+
+| scénář | bez postFetch | s postFetch |
+|---|---|---|
+| iterace přes `$author->getBooks()` (ONE_TO_MANY) | 6 dotazů | **2** |
+| iterace přes `$author->getPublisher()` (MANY_TO_ONE) | 4 dotazy | **3** |
+
+Kolekce jsou po `fetch()` označené jako inicializované a obsahují správné entity (kontrolováno
+i obsahově, ne jen počtem), včetně MANY_TO_MANY, kde `doPostFetch` dělá extra mapping dotaz.
+`addPostFetch('neexistujiciPole')` teď správně hodí výjimku, která byla dosud nedosažitelná.
+
+Pokryto v `tests/QueryObject/PostFetchTest.php` (23 testů). Coverage `QueryObject` vyskočil
+z 75 % na 98 % řádků, protože 101 dosud mrtvých řádků teď testy skutečně prochází.
+
+## Riziko pro existující projekty
+
+**Pro projekt, který `addPostFetch()` nevolá, je chování bit za bit stejné.** Není to domněnka,
+plyne to z toho, že veškerý změněný kód leží za touhle branou:
+
+```php
+final public function postFetch(Iterator $iterator): void
+{
+ if (empty($this->postFetch)) {
+ return; // <- bez addPostFetch() se dál nikdy nedostane
+ }
+ ...
+}
+```
+
+Všech 5 změn v `doPostFetch()` je za tímto `return`em. Šestá změna (odstranění dvojího volání)
+jen ruší volání, které by stejně na tomhle `return`u skončilo.
+
+Pro projekt, který `addPostFetch()` **volá**, se změní tohle: začne se prefetchovat (méně dotazů)
+a neplatné jméno pole začne hlásit výjimku místo tichého ignorování.
+
+### Ověřeno
+
+- **Vlastní sada:** zeleně na PHP 8.4 i 8.5, na SQLite i MySQL 8.0, s nejnovějšími i s nejnižšími
+ povolenými závislostmi (ORM 3.3.0 / DBAL 4.2.1). Kombinace s nejnižšími je důležitá, protože
+ právě na ní se projevil bod 4.
+- **Reálný projekt (prevozpenez):** opravený `src/` nasazený do `vendor/` projektu, pak
+ - PHPStan (level 3, `app` + `tests`): **19 chyb před i po záměně, diff prázdný** — všechny
+ pre-existující a nesouvisející,
+ - Codeception Unit suite: **OK (309 testů, 1535 asercí)**.
+
+ Vendor projektu byl potom vrácen do původního stavu (ověřeno `diff -r`).
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..f7d52cc
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,37 @@
+
+
+
+
+ tests
+
+
+
+
+
+ src
+
+
+
+
+
+
+
+
diff --git a/src/QueryObject/Filters/IsActiveFilterTrait.php b/src/QueryObject/Filters/IsActiveFilterTrait.php
index 3f35619..39d5995 100644
--- a/src/QueryObject/Filters/IsActiveFilterTrait.php
+++ b/src/QueryObject/Filters/IsActiveFilterTrait.php
@@ -6,12 +6,12 @@
trait IsActiveFilterTrait
{
- abstract public function by(array|string $column, mixed $value = null, QueryObjectByMode $mode = QueryObjectByMode::AUTO): static;
+ abstract public function by(array|string $column, mixed $value = null, QueryObjectByMode $mode = QueryObjectByMode::AUTO, ?string $filterKey = null): static;
abstract public function disableFilter(array|string $filter): static;
public function byIsActive(bool $isActive = true): static
{
- return $this->by("isActive", $isActive);
+ return $this->by('isActive', $isActive, QueryObjectByMode::AUTO, IsActiveFilter::IS_ACTIVE_FILTER);
}
public function disableIsActiveFilter(): static
diff --git a/src/QueryObject/QueryObject.php b/src/QueryObject/QueryObject.php
index 1598732..0134f58 100644
--- a/src/QueryObject/QueryObject.php
+++ b/src/QueryObject/QueryObject.php
@@ -2,7 +2,7 @@
namespace ADT\DoctrineComponents\QueryObject;
-use ADT\DoctrineComponents\IEntity;
+use ADT\DoctrineComponents\Entities\Entity;
use ADT\DoctrineComponents\QueryObject\QueryObjectByMode;
use ADT\DoctrineComponents\QueryObject\QueryObjectInterface;
use ADT\DoctrineComponents\QueryObject\ResultSet;
@@ -102,65 +102,62 @@ protected function initSelect(QueryBuilder $qb): void
*********************/
/**
- * @param int|int[]|IEntity|IEntity[]|[]|null $id
+ * @param int|int[]|Entity|Entity[]|[]|null $id
* @return static
*/
public function byId($id): static
{
if (is_iterable($id) && !is_string($id)) {
- foreach ($id as $item) {
- if (is_object($item)) {
- $this->byIdFilter[$item->getId()] = $item->getId();
- }
- else {
- $this->byIdFilter[$item] = $item;
- }
- }
+ $items = is_array($id) ? $id : iterator_to_array($id, false);
- //If we did not fill anything, we want to set an empty array to set the 'id IN (NULL)' in the resulting filters
- if (count($id) === 0) {
+ //An empty input discards previously collected ids and sets 'id IN (NULL)' in the resulting filters
+ if (count($items) === 0) {
$this->byIdFilter = [];
+
+ return $this;
+ }
+
+ //Ids that are null are skipped, but the filter itself is still applied
+ $this->byIdFilter ??= [];
+ foreach ($items as $item) {
+ if (($_id = $this->resolveId($item)) !== null) {
+ $this->byIdFilter[$_id] = $_id;
+ }
}
}
- elseif (is_object($id)) {
- $this->byIdFilter[$id->getId()] = $id->getId();
- }
- //we still want to add 'id IN (null)' if we pass $id=null
- elseif ($id === null) {
- $this->byIdFilter = [];
- }
+ //we still want to add 'id IN (null)' if we pass $id=null or an entity without an id
else {
- $this->byIdFilter[$id] = $id;
+ $this->byIdFilter ??= [];
+ if (($_id = $this->resolveId($id)) !== null) {
+ $this->byIdFilter[$_id] = $_id;
+ }
}
return $this;
}
/**
- * @param int|int[]|IEntity|IEntity[] $id
+ * @param int|int[]|Entity|Entity[] $id
*/
final public function orById($id): static
{
- if (is_iterable($id) && !is_string($id)) {
- foreach ($id as $item) {
- if (is_object($item)) {
- $this->orByIdFilter[$item->getId()] = $item->getId();
- }
- elseif ($item !== null) {
- $this->orByIdFilter[$item] = $item;
- }
+ foreach (is_iterable($id) && !is_string($id) ? $id : [$id] as $item) {
+ if (($_id = $this->resolveId($item)) !== null) {
+ $this->orByIdFilter[$_id] = $_id;
}
}
- elseif (is_object($id)) {
- $this->orByIdFilter[$id->getId()] = $id->getId();
- }
- elseif ($id !== null) {
- $this->orByIdFilter[$id] = $id;
- }
return $this;
}
+ /**
+ * @param int|string|Entity|null $id
+ */
+ private function resolveId(mixed $id): int|string|null
+ {
+ return is_object($id) ? $id->getId() : $id;
+ }
+
final public function disableFilter(array|string $filter): static
{
foreach ((array) $filter as $_filter) {
@@ -184,11 +181,12 @@ final public function disableDefaultOrder(): static
* @param string|string[] $column
* @param mixed $value
* @param QueryObjectByMode $mode
+ * @param string|null $filterKey Pod tímto klíčem lze filtr později vypnout přes disableFilter()
* @return $this
*/
- final public function by(array|string $column, mixed $value = null, QueryObjectByMode $mode = QueryObjectByMode::AUTO): static
+ final public function by(array|string $column, mixed $value = null, QueryObjectByMode $mode = QueryObjectByMode::AUTO, ?string $filterKey = null): static
{
- $this->filter[] = function (QueryBuilder $qb) use ($column, $value, $mode) {
+ $filter = function (QueryBuilder $qb) use ($column, $value, $mode) {
$column = (array) $column;
$this->validateFieldNames($column);
@@ -214,10 +212,12 @@ function($_column) use ($qb, $value, $mode) {
}
if (!in_array($mode, [QueryObjectByMode::IS_NULL, QueryObjectByMode::IS_NOT_NULL, QueryObjectByMode::IS_EMPTY, QueryObjectByMode::IS_NOT_EMPTY])) {
- $paramName = 'by_' . str_replace('.', '_', $_column);
// Pro between chceme rozdelit value do dvou různých podmínek
- if (in_array($mode, [QueryObjectByMode::BETWEEN, QueryObjectByMode::NOT_BETWEEN], true)) {
- $paramName2 = 'by_' . str_replace('.', '_', $_column) . '_2';
+ $isBetween = in_array($mode, [QueryObjectByMode::BETWEEN, QueryObjectByMode::NOT_BETWEEN], true);
+
+ $paramName = $this->getUniqueParamName($qb, 'by_' . str_replace('.', '_', $_column), $isBetween);
+ if ($isBetween) {
+ $paramName2 = $paramName . '_2';
}
}
@@ -327,9 +327,42 @@ function($_column) use ($qb, $value, $mode) {
);
$qb->andWhere($qb->expr()->orX(...$x));
};
+
+ if ($filterKey !== null) {
+ $this->filter[$filterKey] = $filter;
+ } else {
+ $this->filter[] = $filter;
+ }
+
return $this;
}
+ /**
+ * Vrátí název parametru, který v query ještě není použitý.
+ * Stejný sloupec může být filtrovaný vícekrát (např. fulltext hledání a zároveň
+ * další podmínka nad tím samým sloupcem); bez unikátního názvu by pozdější
+ * podmínka přepsala hodnotu parametru té dřívější.
+ *
+ * @param QueryBuilder $qb
+ * @param string $paramName
+ * @param bool $withSecondParam Rezervuje i název pro druhý parametr (between)
+ * @return string
+ * @internal
+ */
+ final protected function getUniqueParamName(QueryBuilder $qb, string $paramName, bool $withSecondParam = false): string
+ {
+ $isUsed = fn(string $name) => $qb->getParameter($name) !== null
+ || ($withSecondParam && $qb->getParameter($name . '_2') !== null);
+
+ $uniqueParamName = $paramName;
+ $i = 1;
+ while ($isUsed($uniqueParamName)) {
+ $uniqueParamName = $paramName . '_' . ++$i;
+ }
+
+ return $uniqueParamName;
+ }
+
/**
* @param array{string: string}|string $field
* @param string|null $order
@@ -348,7 +381,7 @@ final public function orderBy(array|string $field, ?string $order = null): stati
throw new Exception('Parameter "$field" cannot be empty.');
}
- $this->validateFieldNames($field);
+ $this->validateFieldNames(array_keys($field));
$this->addJoins($qb, array_keys($field));
@@ -374,7 +407,6 @@ private function mapToDTO($data): array
foreach ($_row as $_property => $_value) {
if ($reflectionClass->hasProperty($_property)) {
$prop = $reflectionClass->getProperty($_property);
- $prop->setAccessible(true);
$prop->setValue($_dto, $_value);
} else {
throw new Exception('Property ' . $dtoClass . '::' . $_property . ' does not exist.');
@@ -388,8 +420,8 @@ private function mapToDTO($data): array
/** @internal */
final protected function validateFieldNames(array $fields): void
{
- foreach ($fields as $_name => $_order) {
- if (explode('.', $_name)[0] === $this->entityAlias) {
+ foreach ($fields as $_name) {
+ if (explode('.', (string) $_name)[0] === $this->entityAlias) {
throw new Exception('Do not use entity alias in field names.');
}
}
@@ -419,11 +451,28 @@ final public function createQueryBuilder(bool $withSelectAndOrder = true): Query
$this->join = [];
- // we need to use a reference to allow adding a filter inside another filter
- foreach ($this->filter as &$_filter) {
- $_filter->call($this, $qb);
+ // a filter can register another filter, or replace itself under the same key
+ // (a lazily registered filter calling by() with the same $filterKey does exactly that),
+ // so we keep restarting until there is no filter left to apply
+ $applied = [];
+ while (true) {
+ $next = null;
+ foreach ($this->filter as $_key => $_filter) {
+ if (($applied[$_key] ?? null) === $_filter) {
+ continue;
+ }
+
+ $applied[$_key] = $_filter;
+ $next = $_filter;
+ break;
+ }
+
+ if ($next === null) {
+ break;
+ }
+
+ $next->call($this, $qb);
}
- unset ($_filter);
// $forbiddenDQLParts = ['select', 'distinct', 'orderBy'];
// foreach ($forbiddenDQLParts as $_forbiddenDQLPart) {
@@ -432,18 +481,18 @@ final public function createQueryBuilder(bool $withSelectAndOrder = true): Query
// }
// }
- //orById
- if ($this->orByIdFilter && $qb->getDQLPart('where')) {
- $qb->orWhere('e.id IN (:orByIdFilter)')
- ->setParameter('orByIdFilter', $this->orByIdFilter);
- }
-
- //byId
+ //byId has to be applied first, otherwise orById would not see it and would be dropped
if ($this->byIdFilter !== null) {
- $qb->andWhere('e.id IN (:byIdFilter)')
+ $qb->andWhere($this->entityAlias . '.id IN (:byIdFilter)')
->setParameter('byIdFilter', $this->byIdFilter);
}
+ //without any other condition the query already returns everything, so "OR id IN (...)" would be a no-op
+ if ($this->orByIdFilter && $qb->getDQLPart('where')) {
+ $qb->orWhere($this->entityAlias . '.id IN (:orByIdFilter)')
+ ->setParameter('orByIdFilter', $this->orByIdFilter);
+ }
+
if ($withSelectAndOrder) {
$this->initSelect($qb);
@@ -479,13 +528,10 @@ final protected function addJoins(QueryBuilder $qb, array $columns): void
}
$aliasLast = null;
- foreach (explode('.', $column, '-1') as $aliasNew) {
+ foreach (explode('.', $column, -1) as $aliasNew) {
$join = $aliasLast ? $aliasLast . '.' . $aliasNew : $this->addColumnPrefix($aliasNew);
- $filterKey = $this->getJoinFilterKey($join, $aliasNew);
- if (!$this->isAlreadyJoined($filterKey)) {
- // because order is a reserved word
- $this->commonJoin($qb, $joinType, $join, $aliasNew);
- }
+ // because order is a reserved word
+ $this->commonJoin($qb, $joinType, $join, $aliasNew);
$aliasLast = $aliasNew;
}
}
@@ -506,27 +552,24 @@ final protected function getJoinedEntityColumnName(string $column): string
return implode('.', array_slice(explode('.', $column), -2));
}
+ /**
+ * Joins are deduplicated by alias only, the first join for a given alias wins.
+ *
+ * That is intentional and load bearing: a subclass can register a join under an alias before
+ * calling parent::init(), which re-points every inherited join and condition using that alias
+ * to a different relation. Comparing the join target here and failing on a mismatch would
+ * break that pattern.
+ */
private function commonJoin(QueryBuilder $qb, string $joinType, string $join, string $alias, ?string $conditionType = null, ?string $condition = null, ?string $indexBy = null): self
{
- $join = $this->addColumnPrefix($join);
- $filterKey = $this->getJoinFilterKey($join, $alias, $conditionType, $condition, $indexBy);
-
- if (! $this->isAlreadyJoined($filterKey)) {
- $qb->$joinType($join, $alias, $conditionType, $condition, $indexBy);
- $this->join[$filterKey] = true;
+ if (isset($this->join[$alias])) {
+ return $this;
}
- return $this;
- }
-
- private function getJoinFilterKey(string $join, string $alias, ?string $conditionType = null, ?string $condition = null, ?string $indexBy = null): string
- {
- return implode('_', [$alias]);
- }
+ $qb->$joinType($this->addColumnPrefix($join), $alias, $conditionType, $condition, $indexBy);
+ $this->join[$alias] = true;
- private function isAlreadyJoined(string $filterKey): bool
- {
- return isset($this->join[$filterKey]);
+ return $this;
}
/*********
@@ -603,8 +646,6 @@ final public function fetchOne(bool $strict = true, bool $lock = false): object
throw new NonUniqueResultException();
}
- $this->postFetch(new ArrayIterator($result));
-
return $result[0];
}
@@ -627,6 +668,10 @@ final public function fetchOneOrNull(bool $strict = true, bool $lock = false): o
*/
public function fetchPairs(?string $value, ?string $key): array
{
+ if ($key === null) {
+ throw new Exception('Parameter "$key" is required, there is nothing to key the result by.');
+ }
+
$items = [];
foreach ($this->fetch() as $item) {
$_key = $item->{'get' . ucfirst($key)}();
@@ -652,10 +697,10 @@ public function fetchField(string $field, bool $lock = false): array
}
if ($this->em->getClassMetadata($this->getEntityClass())->hasAssociation($field)) {
- $qb->select('IDENTITY(e.' . $field . ') AS field')
- ->groupBy('e.' . $field);
+ $qb->select('IDENTITY(' . $this->entityAlias . '.' . $field . ') AS field')
+ ->groupBy($this->entityAlias . '.' . $field);
} else {
- $qb->select('e.' . $field . ' AS field');
+ $qb->select($this->entityAlias . '.' . $field . ' AS field');
}
$query = $this->getQuery($qb);
@@ -666,7 +711,7 @@ public function fetchField(string $field, bool $lock = false): array
$items = [];
foreach ($query->getResult(AbstractQuery::HYDRATE_SCALAR) as $item) {
- $items[$item['field']] = $item['field'];
+ $items[$item['field'] ?? ''] = $item['field'];
}
return $items;
@@ -724,7 +769,7 @@ private function hasModifiedColumns(QueryBuilder $qb): bool
/**
* @param EntityManagerInterface $em
- * @param IEntity[] $rootEntities Jeden typ entit, např. 10x User.
+ * @param Entity[] $rootEntities Jeden typ entit, např. 10x User.
* @param string[] $fieldNames Názvy relací v hlavní entitě. Pro zanoření použij '.'. Např. [ 'address' ].
* @throws ReflectionException
* @throws Exception
@@ -764,7 +809,7 @@ public static function doPostFetch(EntityManagerInterface $em, array $rootEntiti
$firstRootEntity = $rootEntities[0];
}
- if (!is_object($firstRootEntity) || !($firstRootEntity instanceof IEntity)) {
+ if (!is_object($firstRootEntity) || !($firstRootEntity instanceof Entity)) {
// a není to entita, rychle pryč
return;
}
@@ -789,7 +834,7 @@ public static function doPostFetch(EntityManagerInterface $em, array $rootEntiti
// připravíme QueryBuilder pro vytažení IDček *_TO_ONE asociací, např. z Userů
$qb = $em->getRepository(get_class($firstRootEntity))->createQueryBuilder('e')
- ->select('PARTIAL e.{id} AS e_id')
+ ->select('e.id')
->andWhere('e.id IN (:ids)')
->setParameter('ids', $rootIds);
@@ -800,7 +845,7 @@ public static function doPostFetch(EntityManagerInterface $em, array $rootEntiti
// $fieldName je např. 'address'
$association = $rootEntityAssociations[$fieldName];
- if ($association['type'] & Doctrine\ORM\Mapping\ClassMetadataInfo::TO_ONE) {
+ if ($association['type'] & Doctrine\ORM\Mapping\ClassMetadata::TO_ONE) {
// pokud je asociace *_TO_ONE, tak přidáme select na její ID a zajistíme provedení dotazu
$qb->addSelect('IDENTITY(e.' . $fieldName . ') AS id_' . $i);
@@ -822,7 +867,7 @@ public static function doPostFetch(EntityManagerInterface $em, array $rootEntiti
$association = $rootEntityAssociations[$fieldName];
// $propertyName je název sloupce z druhé strany, např. Address#user
- $propertyName = $association['mappedBy'] ?: $association['inversedBy'];
+ $propertyName = ($association['mappedBy'] ?? null) ?: ($association['inversedBy'] ?? null);
if ($propertyName === NULL) {
throw new Exception("PostFetch rootEntity='{$association['sourceEntity']}', targetEntity='{$association['targetEntity']}': Nelze přiřadit entity k root entitě. Chybí mappedBy nebo inversedBy.");
@@ -833,7 +878,7 @@ public static function doPostFetch(EntityManagerInterface $em, array $rootEntiti
->select('e')
->from($association['targetEntity'], 'e');
- if ($association['type'] & Doctrine\ORM\Mapping\ClassMetadataInfo::TO_ONE) {
+ if ($association['type'] & Doctrine\ORM\Mapping\ClassMetadata::TO_ONE) {
// pokud se jedná a TO_ONE asociaci, posbíráme IDčka připojených entit
// např. u Usera je jen jedna adresa
@@ -855,13 +900,13 @@ public static function doPostFetch(EntityManagerInterface $em, array $rootEntiti
$qb
->orWhere('e.id IN (:ids)')
->setParameter('ids', array_unique($ids));
- } elseif ($association['type'] === Doctrine\ORM\Mapping\ClassMetadataInfo::ONE_TO_MANY) {
+ } elseif ($association['type'] === Doctrine\ORM\Mapping\ClassMetadata::ONE_TO_MANY) {
// u ONE_TO_MANY asociací stačí selectovat podle IDček rootovských entit
// např. jeden User má více adres, v adrese je nastaven User
$qb
->orWhere('e.' . $association['mappedBy'] . ' IN (:ids)')
->setParameter('ids', array_unique($rootIds));
- } elseif ($association['type'] === Doctrine\ORM\Mapping\ClassMetadataInfo::MANY_TO_MANY) {
+ } elseif ($association['type'] === Doctrine\ORM\Mapping\ClassMetadata::MANY_TO_MANY) {
// u MANY_TO_MANY asociací musíme (např. adresu) joinovat s root entitou (User) a pak selectovat podle IDček rootovských entit
$qb
->leftJoin('e.' . $propertyName, $propertyName)
@@ -878,14 +923,11 @@ public static function doPostFetch(EntityManagerInterface $em, array $rootEntiti
// v pripadne TO_ONE nám Doctrine entity přiřadí
// musime tedy poresit jen TO_MANY
- if ($association['type'] & Doctrine\ORM\Mapping\ClassMetadataInfo::TO_MANY) {
+ if ($association['type'] & Doctrine\ORM\Mapping\ClassMetadata::TO_MANY) {
$refCollProperty = new ReflectionProperty(get_class($firstRootEntity), $association['fieldName']);
- $refCollProperty->setAccessible(true);
-
$refInitProperty = new ReflectionProperty(PersistentCollection::class, 'initialized');
- $refInitProperty->setAccessible(true);
- if ($association['type'] === Doctrine\ORM\Mapping\ClassMetadataInfo::MANY_TO_MANY) {
+ if ($association['type'] === Doctrine\ORM\Mapping\ClassMetadata::MANY_TO_MANY) {
// u MANY_TO_MANY relací se nám ztratila informace o tom, která entita patří do jaké kolekce,
// dalším dotazem tedy zjistíme co kam máme dát
@@ -903,10 +945,9 @@ public static function doPostFetch(EntityManagerInterface $em, array $rootEntiti
foreach ($result as $row) {
$collections = [];
- if ($association['type'] !== Doctrine\ORM\Mapping\ClassMetadataInfo::MANY_TO_MANY) {
+ if ($association['type'] !== Doctrine\ORM\Mapping\ClassMetadata::MANY_TO_MANY) {
$reflector = new ReflectionClass($row);
$property = $reflector->getProperty($propertyName);
- $property->setAccessible(true);
$rootEntity = $property->getValue($row);
$collections[] = $refCollProperty->getValue($rootEntity);
} elseif (isset($manyToManyMapping)) {
diff --git a/src/QueryObject/QueryObjectInterface.php b/src/QueryObject/QueryObjectInterface.php
index fdac064..f7a1240 100644
--- a/src/QueryObject/QueryObjectInterface.php
+++ b/src/QueryObject/QueryObjectInterface.php
@@ -6,7 +6,7 @@
interface QueryObjectInterface
{
- public function by(array|string $column, mixed $value, QueryObjectByMode $mode = QueryObjectByMode::AUTO): static;
+ public function by(array|string $column, mixed $value, QueryObjectByMode $mode = QueryObjectByMode::AUTO, ?string $filterKey = null): static;
public function byId($id): static;
public function orderBy(array|string $field, ?string $order = null): static;
diff --git a/tests/BaseListenerTest.php b/tests/BaseListenerTest.php
new file mode 100644
index 0000000..b2aa660
--- /dev/null
+++ b/tests/BaseListenerTest.php
@@ -0,0 +1,356 @@
+em = new EntityManager($inner);
+
+ $this->listener = new RecordingListener();
+ $this->listener->setEntityManager($this->em);
+
+ $inner->getEventManager()->addEventSubscriber($this->listener);
+ }
+
+ protected function tearDown(): void
+ {
+ RecordingListener::resetStaticState();
+ EntityManager::$isFlushAllowed = true;
+ $this->em->getConnection()->close();
+
+ parent::tearDown();
+ }
+
+ public function testItIsADoctrineEventSubscriber(): void
+ {
+ self::assertInstanceOf(EventSubscriber::class, $this->listener);
+ }
+
+ public function testCallbacksAreInvokedDuringFlush(): void
+ {
+ $this->em->persist(new Author('Adam'));
+ $this->em->flush();
+
+ self::assertContains('prePersist', $this->listener->calls);
+ self::assertContains('onFlush', $this->listener->calls);
+ self::assertContains('postPersist', $this->listener->calls);
+ }
+
+ public function testUpdateCallbacksAreInvoked(): void
+ {
+ FixtureLoader::load($this->em);
+ $this->listener->calls = [];
+
+ $this->em->find(Author::class, 1)->setName('Zmena');
+ $this->em->flush();
+
+ self::assertContains('preUpdate', $this->listener->calls);
+ self::assertContains('postUpdate', $this->listener->calls);
+ }
+
+ public function testFlushIsForbiddenInsideCallbacks(): void
+ {
+ $this->em->persist(new Author('Adam'));
+ $this->em->flush();
+
+ self::assertSame(
+ [false, false, false],
+ [
+ $this->listener->flushAllowedDuringCallback['prePersist'],
+ $this->listener->flushAllowedDuringCallback['onFlush'],
+ $this->listener->flushAllowedDuringCallback['postPersist'],
+ ],
+ );
+ }
+
+ public function testFlushInsideAnOnFlushCallbackIsRejected(): void
+ {
+ $this->listener->onFlushHook = function (): void {
+ $this->em->flush();
+ };
+
+ $this->em->persist(new Author('Adam'));
+
+ $isolated = self::runIsolated(fn() => $this->em->flush());
+
+ self::assertInstanceOf(Exception::class, $isolated['throwable']);
+ self::assertSame('You cannot use flush.', $isolated['throwable']->getMessage());
+ }
+
+ public function testFlushIsAllowedAgainAfterAFailedFlush(): void
+ {
+ $this->em->persist(new Author('Adam'));
+ $this->em->flush();
+
+ self::assertTrue(EntityManager::$isFlushAllowed);
+ }
+
+ public function testMissingCallbackThrows(): void
+ {
+ $listener = new IncompleteListener();
+ $listener->setEntityManager($this->em);
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Implement onFlushCallback first.');
+
+ $listener->onFlush(new OnFlushEventArgs($this->em));
+ }
+
+ public function testMissingPrePersistCallbackThrows(): void
+ {
+ $listener = new IncompleteListener();
+ $listener->setEntityManager($this->em);
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Implement prePersistCallback first.');
+
+ $listener->prePersist(new PrePersistEventArgs(new Author('x'), $this->em));
+ }
+
+ public function testMissingPostPersistCallbackThrows(): void
+ {
+ $listener = new IncompleteListener();
+ $listener->setEntityManager($this->em);
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Implement postPersistCallback first.');
+
+ $listener->postPersist(new PostPersistEventArgs(new Author('x'), $this->em));
+ }
+
+ public function testMissingPreUpdateCallbackThrows(): void
+ {
+ $listener = new IncompleteListener();
+ $listener->setEntityManager($this->em);
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Implement preUpdateCallback first.');
+
+ $changeSet = [];
+ $listener->preUpdate(new PreUpdateEventArgs(new Author('x'), $this->em, $changeSet));
+ }
+
+ public function testMissingPostUpdateCallbackThrows(): void
+ {
+ $listener = new IncompleteListener();
+ $listener->setEntityManager($this->em);
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Implement postUpdateCallback first.');
+
+ $listener->postUpdate(new PostUpdateEventArgs(new Author('x'), $this->em));
+ }
+
+ public function testTransactionsOpenedInOnFlushAreClosedByPostFlush(): void
+ {
+ $this->listener->onFlushHook = function (): void {
+ $this->listener->callStartTransaction();
+ $this->listener->callStartTransaction();
+ };
+
+ $this->em->persist(new Author('Adam'));
+ $this->em->flush();
+
+ self::assertSame(0, $this->em->getConnection()->getTransactionNestingLevel());
+ }
+
+ public function testStartTransactionIsCountedAndOnlyStartsOnce(): void
+ {
+ $this->listener->callStartTransaction();
+ $this->listener->callStartTransaction();
+
+ self::assertSame(1, $this->em->getConnection()->getTransactionNestingLevel());
+
+ $this->listener->callCommitTransaction();
+
+ self::assertSame(1, $this->em->getConnection()->getTransactionNestingLevel());
+
+ $this->listener->callCommitTransaction();
+
+ self::assertSame(0, $this->em->getConnection()->getTransactionNestingLevel());
+ }
+
+ public function testCommitWithoutStartThrows(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('No transactions are started for commit');
+
+ $this->listener->callCommitTransaction();
+ }
+
+ public function testPostFlushCallbacksAreInvokedAfterFlush(): void
+ {
+ $invoked = 0;
+ $this->listener->onFlushHook = function () use (&$invoked): void {
+ $this->listener->callAddPostFlushCallback(function () use (&$invoked): void {
+ $invoked++;
+ });
+ };
+
+ $this->em->persist(new Author('Adam'));
+ $this->em->flush();
+
+ self::assertSame(1, $invoked);
+ }
+
+ public function testPostFlushCallbacksAreInvokedOnlyOnce(): void
+ {
+ $invoked = 0;
+ $this->listener->onFlushHook = function () use (&$invoked): void {
+ $this->listener->callAddPostFlushCallback(function () use (&$invoked): void {
+ $invoked++;
+ });
+ };
+
+ $this->em->persist(new Author('Adam'));
+ $this->em->flush();
+
+ $this->listener->onFlushHook = null;
+ $this->em->persist(new Author('Beata'));
+ $this->em->flush();
+
+ self::assertSame(1, $invoked);
+ }
+
+ public function testAddPostFlushCallbackRequiresTheSubscribedEvent(): void
+ {
+ $listener = new IncompleteListener();
+ $listener->setEntityManager($this->em);
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Missing postFlush subsribed event.');
+
+ (new \ReflectionMethod($listener, 'addPostFlushCallback'))->invoke($listener, static fn() => null);
+ }
+
+ public function testPostFlushDetectsUncomputedChanges(): void
+ {
+ FixtureLoader::load($this->em);
+ RecordingListener::resetStaticState();
+
+ $this->listener->onFlushHook = function (): void {
+ $this->em->find(Author::class, 1)->setName('Zmena bez recompute');
+ };
+
+ $isolated = self::runIsolated(function (): void {
+ $this->em->persist(new Author('Trigger'));
+ $this->em->flush();
+ });
+
+ self::assertInstanceOf(Exception::class, $isolated['throwable']);
+ self::assertStringContainsString('You probably did not recompute all changes:', $isolated['throwable']->getMessage());
+ }
+
+ public function testRecomputedChangesArePersisted(): void
+ {
+ FixtureLoader::load($this->em);
+ RecordingListener::resetStaticState();
+
+ $this->listener->onFlushHook = function (): void {
+ $author = $this->em->find(Author::class, 1);
+ $author->setName('Zmena s recompute');
+ $this->listener->markForRecompute($author);
+ };
+
+ $this->em->persist(new Author('Trigger'));
+ $this->em->flush();
+ $this->em->clear();
+
+ self::assertSame('Zmena s recompute', $this->em->find(Author::class, 1)->getName());
+ }
+
+ public function testNewEntitiesCreatedInOnFlushCanBeComputed(): void
+ {
+ $this->listener->onFlushHook = function (): void {
+ $author = new Author('Vytvoreno v onFlush');
+ $this->em->persist($author);
+ $this->listener->markForCompute($author);
+ };
+
+ $this->em->persist(new Author('Trigger'));
+ $this->em->flush();
+ $this->em->clear();
+
+ self::assertSame(2, (int) $this->em->getConnection()->fetchOne('SELECT COUNT(*) FROM author'));
+ }
+
+ public function testIsPropertyChangedDetectsAChangedProperty(): void
+ {
+ FixtureLoader::load($this->em);
+ $detected = [];
+
+ $this->listener->onFlushHook = function () use (&$detected): void {
+ $author = $this->em->find(Author::class, 1);
+ $detected['name'] = $this->listener->callIsPropertyChanged($author, 'name');
+ $detected['email'] = $this->listener->callIsPropertyChanged($author, 'email');
+ $detected['array'] = $this->listener->callIsPropertyChanged($author, ['email', 'name']);
+ };
+
+ $author = $this->em->find(Author::class, 1);
+ $author->setName('Nove jmeno');
+ $this->em->flush();
+
+ self::assertTrue($detected['name']);
+ self::assertFalse($detected['email']);
+ self::assertTrue($detected['array']);
+ }
+
+ public function testIsPropertyChangedReturnsFalseForAnUnchangedEntity(): void
+ {
+ FixtureLoader::load($this->em);
+ $detected = null;
+
+ $this->listener->onFlushHook = function () use (&$detected): void {
+ $detected = $this->listener->callIsPropertyChanged($this->em->find(Author::class, 2), 'name');
+ };
+
+ $this->em->find(Author::class, 1)->setName('Nove jmeno');
+ $this->em->flush();
+
+ self::assertFalse($detected);
+ }
+
+ public function testSubscribedEventsAreDeclared(): void
+ {
+ self::assertSame(
+ [
+ Events::onFlush,
+ Events::prePersist,
+ Events::postPersist,
+ Events::preUpdate,
+ Events::postUpdate,
+ Events::postFlush,
+ ],
+ $this->listener->getSubscribedEvents(),
+ );
+ }
+}
diff --git a/tests/DI/DbalExtensionTest.php b/tests/DI/DbalExtensionTest.php
new file mode 100644
index 0000000..976f792
--- /dev/null
+++ b/tests/DI/DbalExtensionTest.php
@@ -0,0 +1,170 @@
+getByType(Connection::class));
+ }
+
+ public function testTheSqlLoggerIsRegisteredWithThePanelEnabled(): void
+ {
+ $container = self::createContainer(true);
+
+ self::assertInstanceOf(SqlLogger::class, $container->getByType(SqlLogger::class));
+ }
+
+ public function testTheSqlLoggerReceivesTheConfiguredSourcePaths(): void
+ {
+ $container = self::createContainer(true, [__DIR__]);
+ $logger = $container->getByType(SqlLogger::class);
+
+ self::assertSame(
+ [__DIR__],
+ (new \ReflectionProperty(SqlLogger::class, 'sourcePaths'))->getValue($logger),
+ );
+
+ $logger->debug('SELECT 1', ['duration' => 0.1]);
+
+ self::assertStringStartsWith(__DIR__, $logger->getQueries()[0]->source[0]['file']);
+ }
+
+ public function testTheLoggingMiddlewareIsRegistered(): void
+ {
+ $container = self::createContainer(true);
+
+ self::assertInstanceOf(LoggingMiddleware::class, $container->getByType(LoggingMiddleware::class));
+ }
+
+ public function testTheConnectionDriverIsWrappedByTheLoggingMiddleware(): void
+ {
+ $container = self::createContainer(true);
+ $connection = $container->getByType(Connection::class);
+
+ self::assertInstanceOf(LoggingDriver::class, $connection->getDriver());
+ }
+
+ public function testQueriesRunThroughTheContainerConnectionAreLogged(): void
+ {
+ $container = self::createContainer(true);
+ $container->getByType(Connection::class)->executeQuery('SELECT 1');
+
+ $logger = $container->getByType(SqlLogger::class);
+
+ self::assertContains(
+ 'SELECT 1',
+ array_map(static fn(object $query) => $query->sql, $logger->getQueries()),
+ );
+ }
+
+ public function testTheTracyPanelIsRegisteredInTheBar(): void
+ {
+ $container = self::createContainer(true);
+
+ self::assertCount(1, self::queryPanelsOf($container));
+ }
+
+ public function testThePanelUsesTheSameLoggerAsTheMiddleware(): void
+ {
+ $container = self::createContainer(true);
+ $container->getByType(Connection::class)->executeQuery('SELECT 1');
+
+ self::assertStringContainsString('SELECT 1', self::queryPanelsOf($container)[0]->getPanel());
+ }
+
+ public function testNothingIsRegisteredWithThePanelDisabled(): void
+ {
+ $container = self::createContainer(false);
+
+ self::assertInstanceOf(Connection::class, $container->getByType(Connection::class));
+ self::assertSame([], self::queryPanelsOf($container));
+ self::assertNotInstanceOf(LoggingDriver::class, $container->getByType(Connection::class)->getDriver());
+ }
+
+ public function testTheSqlLoggerIsNotRegisteredWithThePanelDisabled(): void
+ {
+ $container = self::createContainer(false);
+
+ $this->expectException(MissingServiceException::class);
+
+ $container->getByType(SqlLogger::class);
+ }
+
+ /**
+ * @param string[] $sourcePaths
+ */
+ private static function createContainer(bool $panel, array $sourcePaths = []): Container
+ {
+ $config = [
+ 'dbal' => [
+ 'debug' => [
+ 'panel' => $panel,
+ 'sourcePaths' => $sourcePaths,
+ ],
+ 'connections' => [
+ 'default' => [
+ 'driver' => 'pdo_sqlite',
+ 'path' => ':memory:',
+ ],
+ ],
+ ],
+ 'services' => [
+ 'tracy.bar' => RecordingBar::class,
+ ],
+ ];
+
+ $tempDir = sys_get_temp_dir() . '/adt-doctrine-components-di';
+ if (!is_dir($tempDir)) {
+ mkdir($tempDir, 0777, true);
+ }
+
+ $loader = new ContainerLoader($tempDir, true);
+ $class = $loader->load(
+ static function (Compiler $compiler) use ($config): void {
+ $compiler->addExtension('dbal', new DbalExtension(true));
+ $compiler->addConfig($config);
+ },
+ serialize($config),
+ );
+
+ $container = new $class();
+ $container->initialize();
+
+ return $container;
+ }
+
+ /**
+ * @return QueryPanel[]
+ */
+ private static function queryPanelsOf(Container $container): array
+ {
+ return array_values(array_filter(
+ $container->getByType(RecordingBar::class)->addedPanels,
+ static fn(object $panel) => $panel instanceof QueryPanel,
+ ));
+ }
+}
diff --git a/tests/DatabaseTestCase.php b/tests/DatabaseTestCase.php
new file mode 100644
index 0000000..0d028c8
--- /dev/null
+++ b/tests/DatabaseTestCase.php
@@ -0,0 +1,35 @@
+em = EntityManagerFactory::create();
+ }
+
+ protected function tearDown(): void
+ {
+ if (isset($this->em)) {
+ $this->em->getConnection()->close();
+ }
+
+ parent::tearDown();
+ }
+
+ protected function loadFixtures(): void
+ {
+ FixtureLoader::load($this->em);
+ }
+}
diff --git a/tests/Entities/IdentifierTest.php b/tests/Entities/IdentifierTest.php
new file mode 100644
index 0000000..db7530f
--- /dev/null
+++ b/tests/Entities/IdentifierTest.php
@@ -0,0 +1,82 @@
+getId());
+ }
+
+ public function testANewEntityIsNew(): void
+ {
+ self::assertTrue((new Author('x'))->isNew());
+ }
+
+ public function testAPersistedEntityIsNotNew(): void
+ {
+ $author = new Author('x');
+ $this->em->persist($author);
+ $this->em->flush();
+
+ self::assertFalse($author->isNew());
+ self::assertSame(1, $author->getId());
+ }
+
+ public function testIdIsAnIntAfterHydration(): void
+ {
+ $this->loadFixtures();
+
+ $author = $this->em->find(Author::class, FixtureLoader::AUTHOR_ADAM);
+
+ self::assertSame(1, $author->getId());
+ }
+
+ public function testCloningResetsTheId(): void
+ {
+ $this->loadFixtures();
+
+ $author = $this->em->find(Author::class, FixtureLoader::AUTHOR_ADAM);
+ $clone = clone $author;
+
+ self::assertSame(1, $author->getId());
+ self::assertNull($clone->getId());
+ self::assertTrue($clone->isNew());
+ }
+
+ public function testACloneCanBePersistedAsANewRow(): void
+ {
+ $this->loadFixtures();
+
+ $clone = clone $this->em->find(Author::class, FixtureLoader::AUTHOR_ADAM);
+ $this->em->persist($clone);
+ $this->em->flush();
+
+ self::assertSame(6, $clone->getId());
+ self::assertSame('Adam Novák', $clone->getName());
+ }
+
+ public function testTheIdColumnIsMappedAsAGeneratedBigintPrimaryKey(): void
+ {
+ $metadata = $this->em->getClassMetadata(Author::class);
+
+ self::assertSame(['id'], $metadata->getIdentifierFieldNames());
+ self::assertSame(Types::BIGINT, $metadata->getTypeOfField('id'));
+ self::assertFalse($metadata->fieldMappings['id']['nullable'] ?? false);
+ self::assertTrue($metadata->isIdGeneratorIdentity());
+ }
+
+ public function testTheTraitSatisfiesTheEntityInterface(): void
+ {
+ self::assertInstanceOf(Entity::class, new Author('x'));
+ }
+}
diff --git a/tests/EntityManagerTest.php b/tests/EntityManagerTest.php
new file mode 100644
index 0000000..70e4755
--- /dev/null
+++ b/tests/EntityManagerTest.php
@@ -0,0 +1,269 @@
+decorated = new EntityManager($this->em);
+ FixtureLoader::load($this->decorated);
+ }
+
+ protected function tearDown(): void
+ {
+ EntityManager::$isFlushAllowed = true;
+
+ parent::tearDown();
+ }
+
+ public function testItIsADoctrineDecorator(): void
+ {
+ self::assertInstanceOf(EntityManagerDecorator::class, $this->decorated);
+ }
+
+ public function testFlushIsAllowedByDefault(): void
+ {
+ self::assertTrue(EntityManager::$isFlushAllowed);
+ }
+
+ public function testFlushPersistsChanges(): void
+ {
+ $this->decorated->persist(new Author('Franta Nový'));
+ $this->decorated->flush();
+ $this->decorated->clear();
+
+ self::assertSame('Franta Nový', $this->decorated->find(Author::class, 6)->getName());
+ }
+
+ public function testFlushThrowsWhenDisabled(): void
+ {
+ EntityManager::$isFlushAllowed = false;
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('You cannot use flush.');
+
+ $this->decorated->flush();
+ }
+
+ public function testFlushDoesNotPersistAnythingWhenDisabled(): void
+ {
+ EntityManager::$isFlushAllowed = false;
+ $this->decorated->persist(new Author('Nikdo'));
+
+ try {
+ $this->decorated->flush();
+ } catch (Exception) {
+ }
+
+ EntityManager::$isFlushAllowed = true;
+ $this->decorated->clear();
+
+ self::assertNull($this->decorated->find(Author::class, 6));
+ }
+
+ public function testFlushIsWrappedInATransaction(): void
+ {
+ $logger = new SqlLogger([]);
+ $em = new EntityManager(EntityManagerFactory::create([new LoggingMiddleware($logger)]));
+
+ $em->persist(new Author('Transakce'));
+ $em->flush();
+
+ $messages = array_map(static fn(object $query) => $query->sql, $logger->getQueries());
+
+ self::assertContains('Beginning transaction', $messages);
+ self::assertContains('Committing transaction', $messages);
+
+ $em->getConnection()->close();
+ }
+
+ public function testIsPossibleToDeleteEntityReturnsFalseWhenAForeignKeyBlocksIt(): void
+ {
+ $em = new EntityManager(EntityManagerFactory::create(foreignKeys: true));
+ FixtureLoader::load($em);
+
+ self::assertFalse($em->isPossibleToDeleteEntity($em->find(Author::class, FixtureLoader::AUTHOR_ADAM)));
+
+ $em->getConnection()->close();
+ }
+
+ public function testIsPossibleToDeleteEntityReturnsTrueWhenNothingReferencesIt(): void
+ {
+ $em = new EntityManager(EntityManagerFactory::create(foreignKeys: true));
+ FixtureLoader::load($em);
+
+ self::assertTrue($em->isPossibleToDeleteEntity($em->find(Author::class, FixtureLoader::AUTHOR_CYRIL)));
+
+ $em->getConnection()->close();
+ }
+
+ public function testIsPossibleToDeleteEntityRollsBackTheProbeDelete(): void
+ {
+ $em = new EntityManager(EntityManagerFactory::create(foreignKeys: true));
+ FixtureLoader::load($em);
+
+ $em->isPossibleToDeleteEntity($em->find(Author::class, FixtureLoader::AUTHOR_CYRIL));
+ $em->clear();
+
+ self::assertNotNull($em->find(Author::class, FixtureLoader::AUTHOR_CYRIL));
+
+ $em->getConnection()->close();
+ }
+
+ public function testFindEntityClassByInterfaceReturnsTheImplementingEntity(): void
+ {
+ self::assertSame(Publisher::class, $this->decorated->findEntityClassByInterface(PublisherMarker::class));
+ }
+
+ public function testFindEntityClassByInterfaceReturnsOneOfSeveralImplementations(): void
+ {
+ self::assertContains(
+ $this->decorated->findEntityClassByInterface(Entity::class),
+ [Author::class, Publisher::class, Tag::class, \ADT\DoctrineComponents\Tests\Fixtures\Entity\Book::class],
+ );
+ }
+
+ public function testFindEntityClassByInterfaceThrowsForAnUnimplementedInterface(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('There is no entity with interface "Countable".');
+
+ $this->decorated->findEntityClassByInterface(\Countable::class);
+ }
+
+ public function testGetLockSendsTheExpectedStatementOnSqlite(): void
+ {
+ self::requireSqlite();
+
+ try {
+ $this->decorated->getLock('my-lock', 5);
+ self::fail('GET_LOCK is not available on SQLite, an exception was expected.');
+ } catch (DriverException $e) {
+ self::assertSame('SELECT GET_LOCK(?, ?)', $e->getQuery()?->getSQL());
+ self::assertSame(['my-lock', 5], $e->getQuery()?->getParams());
+ }
+ }
+
+ public function testGetLockDefaultsToAnInfiniteTimeoutOnSqlite(): void
+ {
+ self::requireSqlite();
+
+ try {
+ $this->decorated->getLock('my-lock');
+ self::fail('GET_LOCK is not available on SQLite, an exception was expected.');
+ } catch (DriverException $e) {
+ self::assertSame(['my-lock', -1], $e->getQuery()?->getParams());
+ }
+ }
+
+ public function testReleaseLockSendsTheExpectedStatementOnSqlite(): void
+ {
+ self::requireSqlite();
+
+ try {
+ $this->decorated->releaseLock('my-lock');
+ self::fail('RELEASE_LOCK is not available on SQLite, an exception was expected.');
+ } catch (DriverException $e) {
+ self::assertSame('SELECT RELEASE_LOCK(?)', $e->getQuery()?->getSQL());
+ self::assertSame(['my-lock'], $e->getQuery()?->getParams());
+ }
+ }
+
+ public function testGetLockAcquiresAndReleaseLockFreesANamedLock(): void
+ {
+ self::requireMysql();
+
+ $connection = $this->decorated->getConnection();
+ $name = 'adt-doctrine-components-test-lock';
+
+ self::assertSame(1, (int) $connection->fetchOne('SELECT IS_FREE_LOCK(?)', [$name]));
+
+ $this->decorated->getLock($name, 5);
+ self::assertSame(0, (int) $connection->fetchOne('SELECT IS_FREE_LOCK(?)', [$name]));
+
+ $this->decorated->releaseLock($name);
+ self::assertSame(1, (int) $connection->fetchOne('SELECT IS_FREE_LOCK(?)', [$name]));
+ }
+
+ public function testGetLockIsReentrantForTheSameConnection(): void
+ {
+ self::requireMysql();
+
+ $name = 'adt-doctrine-components-reentrant-lock';
+
+ $this->decorated->getLock($name, 5);
+ $this->decorated->getLock($name, 5);
+
+ $this->decorated->releaseLock($name);
+ $this->decorated->releaseLock($name);
+
+ self::assertSame(
+ 1,
+ (int) $this->decorated->getConnection()->fetchOne('SELECT IS_FREE_LOCK(?)', [$name]),
+ );
+ }
+
+ public function testGetLockSendsTheExpectedStatementOnMysql(): void
+ {
+ self::requireMysql();
+
+ $logger = new SqlLogger([]);
+ $em = new EntityManager(EntityManagerFactory::create([new LoggingMiddleware($logger)]));
+
+ $em->getLock('adt-doctrine-components-logged-lock', 5);
+ $em->releaseLock('adt-doctrine-components-logged-lock');
+
+ $logged = array_map(static fn(object $query) => $query->sql, $logger->getQueries());
+
+ self::assertContains("SELECT GET_LOCK('adt-doctrine-components-logged-lock', 5)", $logged);
+ self::assertContains("SELECT RELEASE_LOCK('adt-doctrine-components-logged-lock')", $logged);
+
+ $em->getConnection()->close();
+ }
+
+ public function testGetLockDefaultsToAnInfiniteTimeoutOnMysql(): void
+ {
+ self::requireMysql();
+
+ $logger = new SqlLogger([]);
+ $em = new EntityManager(EntityManagerFactory::create([new LoggingMiddleware($logger)]));
+
+ $em->getLock('adt-doctrine-components-default-timeout-lock');
+ $em->releaseLock('adt-doctrine-components-default-timeout-lock');
+
+ self::assertContains(
+ "SELECT GET_LOCK('adt-doctrine-components-default-timeout-lock', -1)",
+ array_map(static fn(object $query) => $query->sql, $logger->getQueries()),
+ );
+
+ $em->getConnection()->close();
+ }
+
+ public function testQueryObjectsCanUseTheDecoratedEntityManager(): void
+ {
+ $authors = (new Fixtures\QueryObject\AuthorQueryObject($this->decorated))->fetch();
+
+ self::assertCount(5, $authors);
+ }
+}
diff --git a/tests/Fixtures/Dto/AuthorNameDto.php b/tests/Fixtures/Dto/AuthorNameDto.php
new file mode 100644
index 0000000..8559b7a
--- /dev/null
+++ b/tests/Fixtures/Dto/AuthorNameDto.php
@@ -0,0 +1,29 @@
+constructorArgument = $row;
+ }
+
+ public function getId(): mixed
+ {
+ return $this->id;
+ }
+
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+}
diff --git a/tests/Fixtures/Dto/EmptyDto.php b/tests/Fixtures/Dto/EmptyDto.php
new file mode 100644
index 0000000..3880f73
--- /dev/null
+++ b/tests/Fixtures/Dto/EmptyDto.php
@@ -0,0 +1,12 @@
+ */
+ #[ORM\OneToMany(targetEntity: Book::class, mappedBy: 'author')]
+ protected Collection $books;
+
+ /** @var Collection */
+ #[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'authors')]
+ #[ORM\JoinTable(name: 'author_tag')]
+ protected Collection $tags;
+
+ public function __construct(string $name)
+ {
+ $this->name = $name;
+ $this->books = new ArrayCollection();
+ $this->tags = new ArrayCollection();
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function setName(string $name): static
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+ public function getEmail(): ?string
+ {
+ return $this->email;
+ }
+
+ public function setEmail(?string $email): static
+ {
+ $this->email = $email;
+ return $this;
+ }
+
+ public function isActive(): bool
+ {
+ return $this->isActive;
+ }
+
+ public function getIsActive(): bool
+ {
+ return $this->isActive;
+ }
+
+ public function setIsActive(bool $isActive): static
+ {
+ $this->isActive = $isActive;
+ return $this;
+ }
+
+ public function getRating(): ?int
+ {
+ return $this->rating;
+ }
+
+ public function setRating(?int $rating): static
+ {
+ $this->rating = $rating;
+ return $this;
+ }
+
+ public function getBirthDate(): ?DateTimeImmutable
+ {
+ return $this->birthDate;
+ }
+
+ public function setBirthDate(?DateTimeImmutable $birthDate): static
+ {
+ $this->birthDate = $birthDate;
+ return $this;
+ }
+
+ public function getPublisher(): ?Publisher
+ {
+ return $this->publisher;
+ }
+
+ public function setPublisher(?Publisher $publisher): static
+ {
+ $this->publisher = $publisher;
+ return $this;
+ }
+
+ /** @return Collection */
+ public function getBooks(): Collection
+ {
+ return $this->books;
+ }
+
+ public function addBook(Book $book): static
+ {
+ if (!$this->books->contains($book)) {
+ $this->books->add($book);
+ }
+ $book->setAuthor($this);
+ return $this;
+ }
+
+ /** @return Collection */
+ public function getTags(): Collection
+ {
+ return $this->tags;
+ }
+
+ public function addTag(Tag $tag): static
+ {
+ if (!$this->tags->contains($tag)) {
+ $this->tags->add($tag);
+ }
+ return $this;
+ }
+}
diff --git a/tests/Fixtures/Entity/Book.php b/tests/Fixtures/Entity/Book.php
new file mode 100644
index 0000000..5eacb5e
--- /dev/null
+++ b/tests/Fixtures/Entity/Book.php
@@ -0,0 +1,80 @@
+title = $title;
+ }
+
+ public function getTitle(): string
+ {
+ return $this->title;
+ }
+
+ public function setTitle(string $title): static
+ {
+ $this->title = $title;
+ return $this;
+ }
+
+ public function getPrice(): ?int
+ {
+ return $this->price;
+ }
+
+ public function setPrice(?int $price): static
+ {
+ $this->price = $price;
+ return $this;
+ }
+
+ public function getPublishedAt(): ?DateTimeImmutable
+ {
+ return $this->publishedAt;
+ }
+
+ public function setPublishedAt(?DateTimeImmutable $publishedAt): static
+ {
+ $this->publishedAt = $publishedAt;
+ return $this;
+ }
+
+ public function getAuthor(): ?Author
+ {
+ return $this->author;
+ }
+
+ public function setAuthor(?Author $author): static
+ {
+ $this->author = $author;
+ return $this;
+ }
+}
diff --git a/tests/Fixtures/Entity/Publisher.php b/tests/Fixtures/Entity/Publisher.php
new file mode 100644
index 0000000..bb23ce8
--- /dev/null
+++ b/tests/Fixtures/Entity/Publisher.php
@@ -0,0 +1,53 @@
+ */
+ #[ORM\OneToMany(targetEntity: Author::class, mappedBy: 'publisher')]
+ protected Collection $authors;
+
+ public function __construct(string $name, ?string $country = null)
+ {
+ $this->name = $name;
+ $this->country = $country;
+ $this->authors = new ArrayCollection();
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function getCountry(): ?string
+ {
+ return $this->country;
+ }
+
+ /** @return Collection */
+ public function getAuthors(): Collection
+ {
+ return $this->authors;
+ }
+}
diff --git a/tests/Fixtures/Entity/Tag.php b/tests/Fixtures/Entity/Tag.php
new file mode 100644
index 0000000..b68f55d
--- /dev/null
+++ b/tests/Fixtures/Entity/Tag.php
@@ -0,0 +1,43 @@
+ */
+ #[ORM\ManyToMany(targetEntity: Author::class, mappedBy: 'tags')]
+ protected Collection $authors;
+
+ public function __construct(string $name)
+ {
+ $this->name = $name;
+ $this->authors = new ArrayCollection();
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ /** @return Collection */
+ public function getAuthors(): Collection
+ {
+ return $this->authors;
+ }
+}
diff --git a/tests/Fixtures/EntityManagerFactory.php b/tests/Fixtures/EntityManagerFactory.php
new file mode 100644
index 0000000..3fb80c0
--- /dev/null
+++ b/tests/Fixtures/EntityManagerFactory.php
@@ -0,0 +1,144 @@
+
+ */
+ public static function connectionParams(): array
+ {
+ if (!self::isMysql()) {
+ return ['driver' => self::DRIVER_SQLITE, 'memory' => true];
+ }
+
+ return [
+ 'driver' => self::DRIVER_MYSQL,
+ 'host' => getenv('DB_HOST') ?: '127.0.0.1',
+ 'port' => (int) (getenv('DB_PORT') ?: 3306),
+ 'user' => getenv('DB_USER') ?: 'root',
+ 'password' => getenv('DB_PASSWORD') ?: '',
+ 'dbname' => getenv('DB_NAME') ?: 'doctrine_components_test',
+ 'charset' => 'utf8mb4',
+ ];
+ }
+
+ public static function createConfiguration(): Configuration
+ {
+ $config = ORMSetup::createAttributeMetadataConfiguration(
+ [__DIR__ . '/Entity'],
+ true,
+ sys_get_temp_dir() . '/adt-doctrine-components-proxies',
+ );
+
+ if (method_exists($config, 'enableNativeLazyObjects')) {
+ $config->enableNativeLazyObjects(true);
+ } elseif (method_exists($config, 'setLazyGhostObjectEnabled')) {
+ $config->setLazyGhostObjectEnabled(true);
+ }
+
+ return $config;
+ }
+
+ /**
+ * @param Middleware[] $middlewares
+ */
+ public static function createConnection(array $middlewares = []): Connection
+ {
+ $dbalConfig = new DbalConfiguration();
+ if ($middlewares) {
+ $dbalConfig->setMiddlewares($middlewares);
+ }
+
+ return DriverManager::getConnection(self::connectionParams(), $dbalConfig);
+ }
+
+ /**
+ * Every call returns an entity manager over an empty database, no matter which driver is used.
+ * SQLite gets a brand new in-memory database, MySQL keeps one schema per process and is emptied instead.
+ *
+ * @param Middleware[] $middlewares
+ */
+ public static function create(array $middlewares = [], bool $foreignKeys = false): EntityManagerInterface
+ {
+ $em = new EntityManager(self::createConnection($middlewares), self::createConfiguration());
+
+ if (self::isMysql()) {
+ if (!self::$sharedSchemaCreated) {
+ self::dropSchema($em);
+ self::createSchema($em);
+ self::$sharedSchemaCreated = true;
+ }
+
+ self::truncateAllTables($em);
+
+ return $em;
+ }
+
+ if ($foreignKeys) {
+ $em->getConnection()->executeStatement('PRAGMA foreign_keys = ON');
+ }
+
+ self::createSchema($em);
+
+ return $em;
+ }
+
+ /**
+ * @param Middleware[] $middlewares
+ */
+ public static function createDecorated(array $middlewares = [], bool $foreignKeys = false): DecoratedEntityManager
+ {
+ return new DecoratedEntityManager(self::create($middlewares, $foreignKeys));
+ }
+
+ public static function createSchema(EntityManagerInterface $em): void
+ {
+ (new SchemaTool($em))->createSchema($em->getMetadataFactory()->getAllMetadata());
+ }
+
+ public static function dropSchema(EntityManagerInterface $em): void
+ {
+ (new SchemaTool($em))->dropSchema($em->getMetadataFactory()->getAllMetadata());
+ }
+
+ public static function truncateAllTables(EntityManagerInterface $em): void
+ {
+ $connection = $em->getConnection();
+
+ $connection->executeStatement('SET FOREIGN_KEY_CHECKS = 0');
+ foreach ($connection->createSchemaManager()->listTableNames() as $_table) {
+ $connection->executeStatement('TRUNCATE TABLE ' . $connection->quoteIdentifier($_table));
+ }
+ $connection->executeStatement('SET FOREIGN_KEY_CHECKS = 1');
+ }
+}
diff --git a/tests/Fixtures/FixtureLoader.php b/tests/Fixtures/FixtureLoader.php
new file mode 100644
index 0000000..14c40d4
--- /dev/null
+++ b/tests/Fixtures/FixtureLoader.php
@@ -0,0 +1,114 @@
+persist($_publisher);
+ }
+
+ $tags = [];
+ foreach (['php', 'sql', 'js'] as $_name) {
+ $tags[] = $_tag = new Tag($_name);
+ $em->persist($_tag);
+ }
+
+ $adam = (new Author('Adam Novák'))
+ ->setEmail('adam@example.com')
+ ->setIsActive(true)
+ ->setRating(5)
+ ->setBirthDate(new DateTimeImmutable('1980-01-15'))
+ ->setPublisher($publishers[0])
+ ->addTag($tags[0])
+ ->addTag($tags[1]);
+
+ $beata = (new Author('Beata Malá'))
+ ->setEmail('beata@example.com')
+ ->setIsActive(true)
+ ->setRating(3)
+ ->setBirthDate(new DateTimeImmutable('1990-06-30'))
+ ->setPublisher($publishers[0])
+ ->addTag($tags[1]);
+
+ $cyril = (new Author('Cyril Velký'))
+ ->setEmail(null)
+ ->setIsActive(false)
+ ->setRating(null)
+ ->setBirthDate(null)
+ ->setPublisher($publishers[1]);
+
+ $david = (new Author('David Adamec'))
+ ->setEmail('david@example.com')
+ ->setIsActive(true)
+ ->setRating(10)
+ ->setBirthDate(new DateTimeImmutable('2000-12-01'))
+ ->setPublisher(null)
+ ->addTag($tags[2]);
+
+ $eva = (new Author('Eva Nová'))
+ ->setEmail('eva@example.com')
+ ->setIsActive(false)
+ ->setRating(7)
+ ->setBirthDate(new DateTimeImmutable('1975-03-20'))
+ ->setPublisher($publishers[2])
+ ->addTag($tags[0]);
+
+ foreach ([$adam, $beata, $cyril, $david, $eva] as $_author) {
+ $em->persist($_author);
+ }
+
+ $books = [
+ [new Book('Alfa kniha'), 100, '2010-01-01', $adam],
+ [new Book('Beta kniha'), 200, '2012-05-05', $adam],
+ [new Book('Gama kniha'), null, null, $beata],
+ [new Book('Delta kniha'), 300, '2020-10-10', $david],
+ [new Book('Sirotek'), 50, null, null],
+ ];
+
+ foreach ($books as [$_book, $_price, $_publishedAt, $_author]) {
+ $_book->setPrice($_price);
+ $_book->setPublishedAt($_publishedAt ? new DateTimeImmutable($_publishedAt) : null);
+ if ($_author) {
+ $_author->addBook($_book);
+ }
+ $em->persist($_book);
+ }
+
+ $em->flush();
+ $em->clear();
+ }
+}
diff --git a/tests/Fixtures/Listener/IncompleteListener.php b/tests/Fixtures/Listener/IncompleteListener.php
new file mode 100644
index 0000000..433ae77
--- /dev/null
+++ b/tests/Fixtures/Listener/IncompleteListener.php
@@ -0,0 +1,16 @@
+record('onFlush');
+
+ if ($this->onFlushHook !== null) {
+ ($this->onFlushHook)($eventArgs);
+ }
+ }
+
+ public function prePersistCallback(PrePersistEventArgs $eventArgs): void
+ {
+ $this->record('prePersist');
+ }
+
+ public function postPersistCallback(PostPersistEventArgs $eventArgs): void
+ {
+ $this->record('postPersist');
+ }
+
+ public function preUpdateCallback(PreUpdateEventArgs $eventArgs): void
+ {
+ $this->record('preUpdate');
+ }
+
+ public function postUpdateCallback(PostUpdateEventArgs $eventArgs): void
+ {
+ $this->record('postUpdate');
+ }
+
+ public function callAddPostFlushCallback(callable $callback): void
+ {
+ $this->addPostFlushCallback($callback);
+ }
+
+ public function callStartTransaction(): void
+ {
+ $this->startTransaction();
+ }
+
+ public function callCommitTransaction(): void
+ {
+ $this->commitTransaction();
+ }
+
+ public function callIsPropertyChanged(object $entity, array|string $property): bool
+ {
+ return $this->isPropertyChanged($entity, $property);
+ }
+
+ public function markForRecompute(object $entity): void
+ {
+ $this->entitiesToRecompute[] = $entity;
+ }
+
+ public function markForCompute(object $entity): void
+ {
+ $this->entitiesToCompute[] = $entity;
+ }
+
+ public static function resetStaticState(): void
+ {
+ (new \ReflectionProperty(BaseListener::class, 'transactionsStartedCount'))->setValue(null, 0);
+ (new \ReflectionProperty(BaseListener::class, 'possibleChangesChecked'))->setValue(null, false);
+ }
+
+ private function record(string $event): void
+ {
+ $this->calls[] = $event;
+ $this->flushAllowedDuringCallback[$event] = EntityManager::$isFlushAllowed;
+ }
+}
diff --git a/tests/Fixtures/PublisherMarker.php b/tests/Fixtures/PublisherMarker.php
new file mode 100644
index 0000000..0237d06
--- /dev/null
+++ b/tests/Fixtures/PublisherMarker.php
@@ -0,0 +1,9 @@
+byIsActive(true);
+ }
+}
diff --git a/tests/Fixtures/QueryObject/AuthorNameDtoQueryObject.php b/tests/Fixtures/QueryObject/AuthorNameDtoQueryObject.php
new file mode 100644
index 0000000..d771f2f
--- /dev/null
+++ b/tests/Fixtures/QueryObject/AuthorNameDtoQueryObject.php
@@ -0,0 +1,29 @@
+dtoClass = $dtoClass;
+ return $this;
+ }
+
+ public function getDTOClass(): ?string
+ {
+ return $this->dtoClass;
+ }
+
+ protected function initSelect(QueryBuilder $qb): void
+ {
+ $qb->select('e.id AS id, e.name AS name');
+ }
+}
diff --git a/tests/Fixtures/QueryObject/AuthorQueryObject.php b/tests/Fixtures/QueryObject/AuthorQueryObject.php
new file mode 100644
index 0000000..12b0678
--- /dev/null
+++ b/tests/Fixtures/QueryObject/AuthorQueryObject.php
@@ -0,0 +1,108 @@
+
+ */
+class AuthorQueryObject extends QueryObject
+{
+ public const FILTER_ACTIVE = 'filter_active';
+ public const FILTER_NAMED = 'filter_named';
+
+ public function getEntityClass(): string
+ {
+ return Author::class;
+ }
+
+ protected function setDefaultOrder(): void
+ {
+ $this->orderBy('id', 'ASC');
+ }
+
+ public function addFilter(string $key, Closure $filter): static
+ {
+ $this->filter[$key] = $filter;
+ return $this;
+ }
+
+ public function addAnonymousFilter(Closure $filter): static
+ {
+ $this->filter[] = $filter;
+ return $this;
+ }
+
+ public function setOrder(?Closure $order): static
+ {
+ $this->order = $order;
+ return $this;
+ }
+
+ public function addHint(string $name, mixed $value): static
+ {
+ $this->hints[$name] = $value;
+ return $this;
+ }
+
+ public function callLeftJoin(QueryBuilder $qb, string $join, string $alias, ?string $conditionType = null, ?string $condition = null, ?string $indexBy = null): static
+ {
+ return $this->leftJoin($qb, $join, $alias, $conditionType, $condition, $indexBy);
+ }
+
+ public function callInnerJoin(QueryBuilder $qb, string $join, string $alias, ?string $conditionType = null, ?string $condition = null, ?string $indexBy = null): static
+ {
+ return $this->innerJoin($qb, $join, $alias, $conditionType, $condition, $indexBy);
+ }
+
+ public function callAddJoins(QueryBuilder $qb, array $columns): void
+ {
+ $this->addJoins($qb, $columns);
+ }
+
+ public function callAddColumnPrefix(?string $column = null): string
+ {
+ return $this->addColumnPrefix($column);
+ }
+
+ public function callGetJoinedEntityColumnName(string $column): string
+ {
+ return $this->getJoinedEntityColumnName($column);
+ }
+
+ public function callGetUniqueParamName(QueryBuilder $qb, string $paramName, bool $withSecondParam = false): string
+ {
+ return $this->getUniqueParamName($qb, $paramName, $withSecondParam);
+ }
+
+ public function callValidateFieldNames(array $fields): void
+ {
+ $this->validateFieldNames($fields);
+ }
+
+ public function getFilterKeys(): array
+ {
+ return array_keys($this->filter);
+ }
+
+ public function hasOrder(): bool
+ {
+ return $this->order !== null;
+ }
+
+ public function getPostFetchFields(): array
+ {
+ return $this->postFetch;
+ }
+
+ public function callGetCountExpr(): string
+ {
+ return $this->getCountExpr();
+ }
+}
diff --git a/tests/Fixtures/QueryObject/AuthorQueryObjectWithoutParentInit.php b/tests/Fixtures/QueryObject/AuthorQueryObjectWithoutParentInit.php
new file mode 100644
index 0000000..601af58
--- /dev/null
+++ b/tests/Fixtures/QueryObject/AuthorQueryObjectWithoutParentInit.php
@@ -0,0 +1,12 @@
+
+ */
+class BookQueryObject extends QueryObject
+{
+ public function getEntityClass(): string
+ {
+ return Book::class;
+ }
+
+ protected function setDefaultOrder(): void
+ {
+ $this->orderBy('id', 'ASC');
+ }
+}
diff --git a/tests/Fixtures/QueryObject/CountingPostFetchQueryObject.php b/tests/Fixtures/QueryObject/CountingPostFetchQueryObject.php
new file mode 100644
index 0000000..5402028
--- /dev/null
+++ b/tests/Fixtures/QueryObject/CountingPostFetchQueryObject.php
@@ -0,0 +1,19 @@
+filter[] = function (QueryBuilder $qb) {
+ $qb->addSelect('e.rating AS rating');
+ };
+ }
+}
diff --git a/tests/Fixtures/QueryObject/GroupedAuthorQueryObject.php b/tests/Fixtures/QueryObject/GroupedAuthorQueryObject.php
new file mode 100644
index 0000000..85fb376
--- /dev/null
+++ b/tests/Fixtures/QueryObject/GroupedAuthorQueryObject.php
@@ -0,0 +1,19 @@
+filter[] = function (QueryBuilder $qb) {
+ $qb->groupBy('e.publisher');
+ };
+ }
+}
diff --git a/tests/Fixtures/QueryObject/HiddenSelectAuthorQueryObject.php b/tests/Fixtures/QueryObject/HiddenSelectAuthorQueryObject.php
new file mode 100644
index 0000000..4631f30
--- /dev/null
+++ b/tests/Fixtures/QueryObject/HiddenSelectAuthorQueryObject.php
@@ -0,0 +1,22 @@
+addSelect('e.rating AS HIDDEN hiddenRating');
+ }
+
+ protected function setDefaultOrder(): void
+ {
+ $this->orderBy('id', 'ASC');
+ }
+}
diff --git a/tests/Fixtures/QueryObject/LazyActiveAuthorQueryObject.php b/tests/Fixtures/QueryObject/LazyActiveAuthorQueryObject.php
new file mode 100644
index 0000000..1cd451e
--- /dev/null
+++ b/tests/Fixtures/QueryObject/LazyActiveAuthorQueryObject.php
@@ -0,0 +1,25 @@
+filter[IsActiveFilter::IS_ACTIVE_FILTER] = fn() => $this->byIsActive();
+ }
+}
diff --git a/tests/Fixtures/QueryObject/ModifiedSelectAuthorQueryObject.php b/tests/Fixtures/QueryObject/ModifiedSelectAuthorQueryObject.php
new file mode 100644
index 0000000..89e7e64
--- /dev/null
+++ b/tests/Fixtures/QueryObject/ModifiedSelectAuthorQueryObject.php
@@ -0,0 +1,17 @@
+addSelect('e.rating AS rating');
+ }
+}
diff --git a/tests/Fixtures/QueryObject/NamedFilterAuthorQueryObject.php b/tests/Fixtures/QueryObject/NamedFilterAuthorQueryObject.php
new file mode 100644
index 0000000..d075c65
--- /dev/null
+++ b/tests/Fixtures/QueryObject/NamedFilterAuthorQueryObject.php
@@ -0,0 +1,25 @@
+filter[self::FILTER_ACTIVE] = function (QueryBuilder $qb) {
+ $qb->andWhere('e.isActive = :init_isActive')
+ ->setParameter('init_isActive', true);
+ };
+
+ $this->filter[self::FILTER_NAMED] = function (QueryBuilder $qb) {
+ $qb->andWhere('e.name != :init_name')
+ ->setParameter('init_name', 'hidden');
+ };
+ }
+}
diff --git a/tests/Fixtures/QueryObject/PublisherQueryObject.php b/tests/Fixtures/QueryObject/PublisherQueryObject.php
new file mode 100644
index 0000000..4f3213f
--- /dev/null
+++ b/tests/Fixtures/QueryObject/PublisherQueryObject.php
@@ -0,0 +1,24 @@
+
+ */
+class PublisherQueryObject extends QueryObject
+{
+ public function getEntityClass(): string
+ {
+ return Publisher::class;
+ }
+
+ protected function setDefaultOrder(): void
+ {
+ $this->orderBy('id', 'ASC');
+ }
+}
diff --git a/tests/Fixtures/RecordingBar.php b/tests/Fixtures/RecordingBar.php
new file mode 100644
index 0000000..b5ac984
--- /dev/null
+++ b/tests/Fixtures/RecordingBar.php
@@ -0,0 +1,21 @@
+addedPanels[] = $panel;
+
+ return $this;
+ }
+}
diff --git a/tests/Logging/LoggingMiddlewareTest.php b/tests/Logging/LoggingMiddlewareTest.php
new file mode 100644
index 0000000..3a3f1c4
--- /dev/null
+++ b/tests/Logging/LoggingMiddlewareTest.php
@@ -0,0 +1,233 @@
+logger = new SqlLogger([]);
+ $this->em = EntityManagerFactory::create([new LoggingMiddleware($this->logger)]);
+ }
+
+ protected function tearDown(): void
+ {
+ $this->em->getConnection()->close();
+
+ parent::tearDown();
+ }
+
+ public function testMiddlewareImplementsTheDbalInterface(): void
+ {
+ self::assertInstanceOf(Middleware::class, new LoggingMiddleware($this->logger));
+ }
+
+ public function testMiddlewareWrapsTheDriver(): void
+ {
+ self::assertInstanceOf(Driver::class, $this->wrappedDriver());
+ }
+
+ public function testConnectingIsLoggedWithMaskedPassword(): void
+ {
+ $params = EntityManagerFactory::connectionParams();
+ $params['password'] = 'secret';
+
+ $expected = $params;
+ $expected['password'] = '';
+
+ self::runIsolated(fn() => $this->wrappedDriver()->connect($params));
+
+ self::assertSame($expected, $this->logger->getParams());
+ self::assertNotContains('secret', $this->logger->getParams());
+ }
+
+ public function testConnectingWithoutAPasswordIsLoggedUnchanged(): void
+ {
+ $params = EntityManagerFactory::connectionParams();
+ unset($params['password']);
+
+ self::runIsolated(fn() => $this->wrappedDriver()->connect($params));
+
+ self::assertSame($params, $this->logger->getParams());
+ }
+
+ public function testPreparedStatementsAreWrapped(): void
+ {
+ $connection = $this->wrappedDriver()->connect(EntityManagerFactory::connectionParams());
+
+ self::assertInstanceOf(Statement::class, $connection->prepare('SELECT 1'));
+ }
+
+ public function testStatementParametersAreLoggedInBindingOrder(): void
+ {
+ $connection = $this->wrappedDriver()->connect(EntityManagerFactory::connectionParams());
+ $statement = $connection->prepare('SELECT ?, ?');
+ $statement->bindValue(1, 'a', ParameterType::STRING);
+ $statement->bindValue(2, 'b', ParameterType::STRING);
+ $statement->execute();
+
+ self::assertContains("SELECT 'a', 'b'", $this->loggedSql());
+ }
+
+ public function testQueryWithoutParametersIsLoggedVerbatim(): void
+ {
+ $this->em->getConnection()->executeQuery('SELECT 1');
+
+ self::assertContains('SELECT 1', $this->loggedSql());
+ }
+
+ public function testStatementWithoutParametersIsLoggedVerbatim(): void
+ {
+ $this->em->getConnection()->executeStatement('DELETE FROM book WHERE 1 = 0');
+
+ self::assertContains('DELETE FROM book WHERE 1 = 0', $this->loggedSql());
+ }
+
+ public function testEveryLoggedQueryHasANonNegativeDuration(): void
+ {
+ $this->em->getConnection()->executeQuery('SELECT 1');
+
+ foreach ($this->logger->getQueries() as $_query) {
+ self::assertIsFloat($_query->duration);
+ self::assertGreaterThanOrEqual(0.0, $_query->duration);
+ }
+ }
+
+ public function testTotalTimeIsTheSumOfAllDurations(): void
+ {
+ $this->em->getConnection()->executeQuery('SELECT 1');
+ $this->em->getConnection()->executeQuery('SELECT 2');
+
+ $sum = array_sum(array_map(static fn(object $query) => $query->duration, $this->logger->getQueries()));
+
+ self::assertSame($sum, $this->logger->getTotalTime());
+ }
+
+ public function testTransactionsAreLogged(): void
+ {
+ $connection = $this->em->getConnection();
+
+ $connection->beginTransaction();
+ $connection->commit();
+ $connection->beginTransaction();
+ $connection->rollBack();
+
+ self::assertSame(
+ ['Beginning transaction', 'Committing transaction', 'Beginning transaction', 'Rolling back transaction'],
+ array_values(array_filter($this->loggedSql(), static fn(string $sql) => str_contains($sql, 'transaction'))),
+ );
+ }
+
+ public function testTransactionsStillWork(): void
+ {
+ FixtureLoader::load($this->em);
+ $connection = $this->em->getConnection();
+
+ $connection->beginTransaction();
+ $connection->executeStatement('DELETE FROM book');
+ $connection->rollBack();
+
+ self::assertSame(5, (int) $connection->fetchOne('SELECT COUNT(*) FROM book'));
+ }
+
+ public function testStringParametersAreQuotedInTheLoggedSql(): void
+ {
+ $this->em->getConnection()->executeQuery('SELECT ?', ['Adam Novák']);
+
+ self::assertContains("SELECT 'Adam Novák'", $this->loggedSql());
+ }
+
+ public function testIntegerParametersAreInlinedWithoutQuotes(): void
+ {
+ $this->em->getConnection()->executeQuery('SELECT ?', [42]);
+
+ self::assertContains('SELECT 42', $this->loggedSql());
+ }
+
+ public function testPercentSignsInParametersAreNotConsumedByTheFormatter(): void
+ {
+ FixtureLoader::load($this->em);
+
+ (new AuthorQueryObject($this->em))->by('name', 'Nov', Mode::CONTAINS)->fetch();
+
+ $likeQueries = array_values(array_filter($this->loggedSql(), static fn(string $sql) => str_contains($sql, 'LIKE')));
+
+ self::assertCount(1, $likeQueries);
+ self::assertStringContainsString("LIKE '%Nov%'", $likeQueries[0]);
+ }
+
+ public function testArrayParametersAreExpandedInTheLoggedSql(): void
+ {
+ FixtureLoader::load($this->em);
+
+ (new AuthorQueryObject($this->em))->byId([1, 2, 3])->fetch();
+
+ $inQueries = array_values(array_filter($this->loggedSql(), static fn(string $sql) => str_contains($sql, ' IN (')));
+
+ self::assertCount(1, $inQueries);
+ self::assertStringContainsString('IN (1, 2, 3)', $inQueries[0]);
+ }
+
+ public function testInsertParametersAreInlined(): void
+ {
+ FixtureLoader::load($this->em);
+
+ $inserts = array_values(array_filter($this->loggedSql(), static fn(string $sql) => str_starts_with($sql, 'INSERT INTO author')));
+
+ self::assertNotSame([], $inserts);
+ self::assertStringContainsString("'Adam Novák'", $inserts[0]);
+ self::assertStringNotContainsString('?', $inserts[0]);
+ }
+
+ public function testNullParametersAreInlinedAsEmpty(): void
+ {
+ $this->em->getConnection()->executeQuery('SELECT ? IS NULL', [null]);
+
+ $matches = array_values(array_filter($this->loggedSql(), static fn(string $sql) => str_contains($sql, 'IS NULL')));
+
+ self::assertSame(['SELECT IS NULL'], $matches);
+ }
+
+ public function testLoggingDoesNotChangeQueryResults(): void
+ {
+ FixtureLoader::load($this->em);
+
+ self::assertSame([1, 2, 3, 4, 5], self::idsOf((new AuthorQueryObject($this->em))->fetch()));
+ }
+
+ private function wrappedDriver(): DbalDriver
+ {
+ return (new LoggingMiddleware($this->logger))->wrap(EntityManagerFactory::createConnection()->getDriver());
+ }
+
+ /**
+ * @return string[]
+ */
+ private function loggedSql(): array
+ {
+ return array_map(static fn(object $query) => $query->sql, $this->logger->getQueries());
+ }
+}
diff --git a/tests/Logging/StatementTest.php b/tests/Logging/StatementTest.php
new file mode 100644
index 0000000..5ab4c16
--- /dev/null
+++ b/tests/Logging/StatementTest.php
@@ -0,0 +1,147 @@
+createStatement());
+ }
+
+ public function testSqlWithoutParametersIsReturnedUnchanged(): void
+ {
+ $sql = 'SELECT * FROM author WHERE name LIKE \'%a%\' AND id = ?';
+
+ self::assertSame($sql, $this->createStatement()->formatSql($sql, [], []));
+ }
+
+ /**
+ * @return array, 2: array, 3: string}>
+ */
+ public static function provideFormattedSql(): array
+ {
+ return [
+ 'string is quoted' => [
+ 'SELECT ?',
+ [1 => 'Adam'],
+ [1 => ParameterType::STRING],
+ "SELECT 'Adam'",
+ ],
+ 'integer is inlined' => [
+ 'SELECT ?',
+ [1 => 42],
+ [1 => ParameterType::INTEGER],
+ 'SELECT 42',
+ ],
+ 'float is inlined' => [
+ 'SELECT ?',
+ [1 => 1.5],
+ [1 => ParameterType::STRING],
+ 'SELECT 1.5',
+ ],
+ 'null becomes empty' => [
+ 'SELECT ?',
+ [1 => null],
+ [1 => ParameterType::NULL],
+ 'SELECT ',
+ ],
+ 'boolean is inlined' => [
+ 'SELECT ?',
+ [1 => true],
+ [1 => ParameterType::BOOLEAN],
+ 'SELECT 1',
+ ],
+ 'multiple placeholders keep their order' => [
+ 'SELECT ?, ?',
+ [1 => 'a', 2 => 'b'],
+ [1 => ParameterType::STRING, 2 => ParameterType::STRING],
+ "SELECT 'a', 'b'",
+ ],
+ 'percent signs are preserved' => [
+ 'SELECT * FROM author WHERE name LIKE ?',
+ [1 => '%Nov%'],
+ [1 => ParameterType::STRING],
+ "SELECT * FROM author WHERE name LIKE '%Nov%'",
+ ],
+ ];
+ }
+
+ /**
+ * @param array $params
+ * @param array $types
+ */
+ #[DataProvider('provideFormattedSql')]
+ public function testFormatSql(string $sql, array $params, array $types, string $expected): void
+ {
+ self::assertSame($expected, $this->createStatement()->formatSql($sql, $params, $types));
+ }
+
+ public function testQuotesInsideAValueAreEscapedBySqlite(): void
+ {
+ self::requireSqlite();
+
+ self::assertSame(
+ "SELECT 'O''Brien'",
+ $this->createStatement()->formatSql('SELECT ?', [1 => "O'Brien"], [1 => ParameterType::STRING]),
+ );
+ }
+
+ public function testQuotesInsideAValueAreEscapedByMysql(): void
+ {
+ self::requireMysql();
+
+ self::assertSame(
+ "SELECT 'O\\'Brien'",
+ $this->createStatement()->formatSql('SELECT ?', [1 => "O'Brien"], [1 => ParameterType::STRING]),
+ );
+ }
+
+ public function testNamedParametersCannotBeFormatted(): void
+ {
+ $statement = $this->createStatement();
+
+ $isolated = self::runIsolated(
+ static fn() => $statement->formatSql('SELECT :name', ['name' => 'Adam'], ['name' => ParameterType::STRING]),
+ );
+
+ self::assertInstanceOf(Error::class, $isolated['throwable']);
+ self::assertStringContainsString('getDatabasePlatform', $isolated['throwable']->getMessage());
+ }
+
+ public function testArrayParameterTypesCannotBeFormatted(): void
+ {
+ $statement = $this->createStatement();
+
+ $isolated = self::runIsolated(
+ static fn() => $statement->formatSql('SELECT ?', [1 => [1, 2]], [1 => ArrayParameterType::INTEGER]),
+ );
+
+ self::assertInstanceOf(Error::class, $isolated['throwable']);
+ self::assertStringContainsString('getDatabasePlatform', $isolated['throwable']->getMessage());
+ }
+
+ private function createStatement(string $sql = 'SELECT 1'): Statement
+ {
+ $driver = (new LoggingMiddleware(new SqlLogger([])))
+ ->wrap(EntityManagerFactory::createConnection()->getDriver());
+
+ $statement = $driver->connect(EntityManagerFactory::connectionParams())->prepare($sql);
+ self::assertInstanceOf(Statement::class, $statement);
+
+ return $statement;
+ }
+}
diff --git a/tests/QueryObject/ByIdTest.php b/tests/QueryObject/ByIdTest.php
new file mode 100644
index 0000000..5d17532
--- /dev/null
+++ b/tests/QueryObject/ByIdTest.php
@@ -0,0 +1,214 @@
+em))->createQueryBuilder(),
+ );
+ }
+
+ public function testScalarIdProducesInCondition(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->byId(3)->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.id IN (:byIdFilter)', $qb);
+ self::assertSame(['byIdFilter' => [3 => 3]], self::paramMap($qb));
+ }
+
+ public function testScalarIdFiltersRows(): void
+ {
+ $this->loadFixtures();
+
+ self::assertSame(
+ [FixtureLoader::AUTHOR_CYRIL],
+ self::idsOf((new AuthorQueryObject($this->em))->byId(FixtureLoader::AUTHOR_CYRIL)->fetch()),
+ );
+ }
+
+ public function testNumericStringIdIsAccepted(): void
+ {
+ $this->loadFixtures();
+
+ self::assertSame(
+ [FixtureLoader::AUTHOR_BEATA],
+ self::idsOf((new AuthorQueryObject($this->em))->byId('2')->fetch()),
+ );
+ }
+
+ public function testArrayOfIdsIsDeduplicatedAndKeyedById(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->byId([3, 4, 3])->createQueryBuilder();
+
+ self::assertSame(['byIdFilter' => [3 => 3, 4 => 4]], self::paramMap($qb));
+ }
+
+ public function testArrayOfIdsFiltersRows(): void
+ {
+ $this->loadFixtures();
+
+ self::assertSame(
+ [FixtureLoader::AUTHOR_BEATA, FixtureLoader::AUTHOR_DAVID],
+ self::idsOf((new AuthorQueryObject($this->em))->byId([4, 2])->fetch()),
+ );
+ }
+
+ public function testEntityIsAccepted(): void
+ {
+ $this->loadFixtures();
+
+ $author = $this->em->find(Author::class, FixtureLoader::AUTHOR_BEATA);
+
+ self::assertSame(
+ [FixtureLoader::AUTHOR_BEATA],
+ self::idsOf((new AuthorQueryObject($this->em))->byId($author)->fetch()),
+ );
+ }
+
+ public function testArrayOfEntitiesIsAccepted(): void
+ {
+ $this->loadFixtures();
+
+ $authors = [
+ $this->em->find(Author::class, FixtureLoader::AUTHOR_BEATA),
+ $this->em->find(Author::class, FixtureLoader::AUTHOR_DAVID),
+ ];
+
+ self::assertSame(
+ [FixtureLoader::AUTHOR_BEATA, FixtureLoader::AUTHOR_DAVID],
+ self::idsOf((new AuthorQueryObject($this->em))->byId($authors)->fetch()),
+ );
+ }
+
+ public function testUnpersistedEntityMatchesNothing(): void
+ {
+ $this->loadFixtures();
+
+ self::assertSame([], (new AuthorQueryObject($this->em))->byId(new Author('nobody'))->fetch());
+ }
+
+ public function testEmptyArrayProducesAnAlwaysFalseCondition(): void
+ {
+ $this->loadFixtures();
+
+ $qb = (new AuthorQueryObject($this->em))->byId([])->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.id IN (:byIdFilter)', $qb);
+ self::assertSame(['byIdFilter' => []], self::paramMap($qb));
+ self::assertSame([], (new AuthorQueryObject($this->em))->byId([])->fetch());
+ }
+
+ public function testNullProducesAnAlwaysFalseCondition(): void
+ {
+ $this->loadFixtures();
+
+ $qb = (new AuthorQueryObject($this->em))->byId(null)->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.id IN (:byIdFilter)', $qb);
+ self::assertSame(['byIdFilter' => []], self::paramMap($qb));
+ self::assertSame([], (new AuthorQueryObject($this->em))->byId(null)->fetch());
+ }
+
+ public function testRepeatedCallsAreMergedIntoOneCondition(): void
+ {
+ $this->loadFixtures();
+
+ $qb = (new AuthorQueryObject($this->em))->byId(1)->byId([2, 3])->createQueryBuilder();
+
+ self::assertSame(1, substr_count(self::normalizeDql($qb->getDQL()), 'e.id IN (:byIdFilter)'));
+ self::assertSame(['byIdFilter' => [1 => 1, 2 => 2, 3 => 3]], self::paramMap($qb));
+ }
+
+ public function testEmptyArrayAfterAScalarDiscardsThePreviousIds(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->byId(1)->byId([])->createQueryBuilder();
+
+ self::assertSame(['byIdFilter' => []], self::paramMap($qb));
+ }
+
+ public function testByIdIsCombinedWithOtherFiltersUsingAnd(): void
+ {
+ $this->loadFixtures();
+
+ $authors = (new AuthorQueryObject($this->em))
+ ->by('isActive', true)
+ ->byId([FixtureLoader::AUTHOR_ADAM, FixtureLoader::AUTHOR_CYRIL])
+ ->fetch();
+
+ self::assertSame([FixtureLoader::AUTHOR_ADAM], self::idsOf($authors));
+ }
+
+ public function testByIdIsAppliedAfterFiltersSoItIsNeverOverwritten(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))
+ ->byId(1)
+ ->by('name', 'x')
+ ->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.name = :by_name AND e.id IN (:byIdFilter)', $qb);
+ }
+
+ public function testNullInsideAnArrayIsSkipped(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->byId([1, null])->createQueryBuilder();
+
+ self::assertSame(['byIdFilter' => [1 => 1]], self::paramMap($qb));
+ }
+
+ public function testAnArrayOfOnlyNullsStillProducesAnAlwaysFalseCondition(): void
+ {
+ $this->loadFixtures();
+
+ $qb = (new AuthorQueryObject($this->em))->byId([null, null])->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.id IN (:byIdFilter)', $qb);
+ self::assertSame(['byIdFilter' => []], self::paramMap($qb));
+ self::assertSame([], (new AuthorQueryObject($this->em))->byId([null])->fetch());
+ }
+
+ public function testAnUnpersistedEntityStillProducesAnAlwaysFalseCondition(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->byId(new Author('nobody'))->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.id IN (:byIdFilter)', $qb);
+ self::assertSame(['byIdFilter' => []], self::paramMap($qb));
+ }
+
+ public function testAGeneratorOfIdsIsAccepted(): void
+ {
+ $this->loadFixtures();
+
+ $ids = (static function (): \Generator {
+ yield 2;
+ yield 4;
+ })();
+
+ self::assertSame(
+ [FixtureLoader::AUTHOR_BEATA, FixtureLoader::AUTHOR_DAVID],
+ self::idsOf((new AuthorQueryObject($this->em))->byId($ids)->fetch()),
+ );
+ }
+
+ public function testAnEmptyGeneratorProducesAnAlwaysFalseCondition(): void
+ {
+ $this->loadFixtures();
+
+ $ids = (static function (): \Generator {
+ return;
+ yield 1;
+ })();
+
+ self::assertSame([], (new AuthorQueryObject($this->em))->byId($ids)->fetch());
+ }
+}
diff --git a/tests/QueryObject/ByModeTest.php b/tests/QueryObject/ByModeTest.php
new file mode 100644
index 0000000..e9cf7cd
--- /dev/null
+++ b/tests/QueryObject/ByModeTest.php
@@ -0,0 +1,167 @@
+}>
+ */
+ public static function provideModes(): array
+ {
+ return [
+ 'AUTO' => ['rating', Mode::AUTO, 5, 'e.rating = :by_rating', ['by_rating' => 5]],
+ 'EQUALS' => ['rating', Mode::EQUALS, 5, 'e.rating = :by_rating', ['by_rating' => 5]],
+ 'NOT_EQUALS' => ['rating', Mode::NOT_EQUALS, 5, 'e.rating != :by_rating', ['by_rating' => 5]],
+ 'STARTS_WITH' => ['name', Mode::STARTS_WITH, 'Ad', 'e.name LIKE :by_name', ['by_name' => 'Ad%']],
+ 'ENDS_WITH' => ['name', Mode::ENDS_WITH, 'ák', 'e.name LIKE :by_name', ['by_name' => '%ák']],
+ 'CONTAINS' => ['name', Mode::CONTAINS, 'da', 'e.name LIKE :by_name', ['by_name' => '%da%']],
+ 'NOT_CONTAINS' => ['name', Mode::NOT_CONTAINS, 'da', 'e.name NOT LIKE :by_name', ['by_name' => '%da%']],
+ 'IS_NULL' => ['email', Mode::IS_NULL, null, 'e.email IS NULL', []],
+ 'IS_NOT_NULL' => ['email', Mode::IS_NOT_NULL, null, 'e.email IS NOT NULL', []],
+ 'IN_ARRAY' => ['rating', Mode::IN_ARRAY, [3, 5], 'e.rating IN (:by_rating)', ['by_rating' => [3, 5]]],
+ 'NOT_IN_ARRAY' => ['rating', Mode::NOT_IN_ARRAY, [3, 5], 'e.rating NOT IN (:by_rating)', ['by_rating' => [3, 5]]],
+ 'GREATER' => ['rating', Mode::GREATER, 5, 'e.rating > :by_rating', ['by_rating' => 5]],
+ 'GREATER_OR_EQUAL' => ['rating', Mode::GREATER_OR_EQUAL, 5, 'e.rating >= :by_rating', ['by_rating' => 5]],
+ 'LESS' => ['rating', Mode::LESS, 5, 'e.rating < :by_rating', ['by_rating' => 5]],
+ 'LESS_OR_EQUAL' => ['rating', Mode::LESS_OR_EQUAL, 5, 'e.rating <= :by_rating', ['by_rating' => 5]],
+ 'BETWEEN' => ['rating', Mode::BETWEEN, [3, 7], 'e.rating BETWEEN :by_rating AND :by_rating_2', ['by_rating' => 3, 'by_rating_2' => 7]],
+ 'NOT_BETWEEN' => ['rating', Mode::NOT_BETWEEN, [3, 7], 'e.rating NOT BETWEEN :by_rating AND :by_rating_2', ['by_rating' => 3, 'by_rating_2' => 7]],
+ 'MEMBER_OF' => ['tags', Mode::MEMBER_OF, 1, ':by_tags MEMBER OF e.tags', ['by_tags' => 1]],
+ 'NOT_MEMBER_OF' => ['tags', Mode::NOT_MEMBER_OF, 1, ':by_tags NOT MEMBER OF e.tags', ['by_tags' => 1]],
+ 'IS_EMPTY' => ['books', Mode::IS_EMPTY, null, 'e.books IS EMPTY', []],
+ 'IS_NOT_EMPTY' => ['books', Mode::IS_NOT_EMPTY, null, 'e.books IS NOT EMPTY', []],
+ ];
+ }
+
+ /**
+ * @param array $expectedParams
+ */
+ #[DataProvider('provideModes')]
+ public function testModeProducesExpectedDqlAndParameters(string $column, Mode $mode, mixed $value, string $expectedCondition, array $expectedParams): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by($column, $value, $mode)->createQueryBuilder();
+
+ self::assertDqlSame(
+ 'SELECT e FROM ' . Author::class . ' e WHERE ' . $expectedCondition . ' ORDER BY e.id ASC',
+ $qb,
+ );
+
+ ksort($expectedParams);
+ self::assertSame($expectedParams, self::paramMap($qb));
+ }
+
+ /**
+ * @param array $expectedParams
+ */
+ #[DataProvider('provideModes')]
+ public function testModeProducesExecutableQuery(string $column, Mode $mode, mixed $value, string $expectedCondition, array $expectedParams): void
+ {
+ $this->loadFixtures();
+
+ self::assertIsArray((new AuthorQueryObject($this->em))->by($column, $value, $mode)->fetch());
+ }
+
+ public function testEveryEnumCaseIsCoveredByTheProvider(): void
+ {
+ $covered = array_keys(self::provideModes());
+ $all = array_map(static fn(Mode $mode) => $mode->name, Mode::cases());
+
+ self::assertSame([], array_values(array_diff($all, $covered)));
+ }
+
+ public function testAutoResolvesNullToIsNull(): void
+ {
+ self::assertDqlContains(
+ 'WHERE e.email IS NULL',
+ (new AuthorQueryObject($this->em))->by('email', null)->createQueryBuilder(),
+ );
+ }
+
+ public function testAutoResolvesArrayToInArray(): void
+ {
+ self::assertDqlContains(
+ 'WHERE e.rating IN (:by_rating)',
+ (new AuthorQueryObject($this->em))->by('rating', [1, 2])->createQueryBuilder(),
+ );
+ }
+
+ public function testAutoResolvesScalarToEquals(): void
+ {
+ self::assertDqlContains(
+ 'WHERE e.name = :by_name',
+ (new AuthorQueryObject($this->em))->by('name', 'Adam Novák')->createQueryBuilder(),
+ );
+ }
+
+ public function testAutoResolvesEmptyArrayToInArray(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by('rating', [])->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.rating IN (:by_rating)', $qb);
+ self::assertSame(['by_rating' => []], self::paramMap($qb));
+ }
+
+ public function testBetweenWithNullLowerBoundBecomesLessOrEqual(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by('rating', [null, 7], Mode::BETWEEN)->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.rating <= :by_rating', $qb);
+ self::assertSame(['by_rating' => 7], self::paramMap($qb));
+ }
+
+ public function testBetweenWithNullUpperBoundBecomesGreaterOrEqual(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by('rating', [3, null], Mode::BETWEEN)->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.rating >= :by_rating', $qb);
+ self::assertSame(['by_rating' => 3], self::paramMap($qb));
+ }
+
+ public function testBetweenWithBothBoundsNullDegradesToLessOrEqualNull(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by('rating', [null, null], Mode::BETWEEN)->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.rating <= :by_rating', $qb);
+ self::assertSame(['by_rating' => null], self::paramMap($qb));
+ }
+
+ public function testNotBetweenKeepsBothBoundsEvenWhenOneIsNull(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by('rating', [null, 7], Mode::NOT_BETWEEN)->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.rating NOT BETWEEN :by_rating AND :by_rating_2', $qb);
+ self::assertSame(['by_rating' => null, 'by_rating_2' => 7], self::paramMap($qb));
+ }
+
+ public function testNullCheckModesIgnoreTheGivenValue(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by('email', 'ignored', Mode::IS_NOT_NULL)->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.email IS NOT NULL', $qb);
+ self::assertSame([], self::paramMap($qb));
+ }
+
+ public function testEmptyCheckModesIgnoreTheGivenValue(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by('books', 'ignored', Mode::IS_EMPTY)->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.books IS EMPTY', $qb);
+ self::assertSame([], self::paramMap($qb));
+ }
+
+ public function testLikeModesStringifyNonStringValues(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by('rating', 5, Mode::CONTAINS)->createQueryBuilder();
+
+ self::assertSame(['by_rating' => '%5%'], self::paramMap($qb));
+ }
+}
diff --git a/tests/QueryObject/ByTest.php b/tests/QueryObject/ByTest.php
new file mode 100644
index 0000000..6f41337
--- /dev/null
+++ b/tests/QueryObject/ByTest.php
@@ -0,0 +1,419 @@
+loadFixtures();
+
+ $authors = (new AuthorQueryObject($this->em))->by('isActive', false)->fetch();
+
+ self::assertSame([FixtureLoader::AUTHOR_CYRIL, FixtureLoader::AUTHOR_EVA], self::idsOf($authors));
+ }
+
+ public function testMultipleColumnsAreCombinedWithOr(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by(['name', 'email'], 'x', Mode::CONTAINS)->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.name LIKE :by_name OR e.email LIKE :by_email', $qb);
+ self::assertSame(['by_email' => '%x%', 'by_name' => '%x%'], self::paramMap($qb));
+ }
+
+ public function testMultipleColumnsMatchRowsFromEitherColumn(): void
+ {
+ $this->loadFixtures();
+
+ $authors = (new AuthorQueryObject($this->em))->by(['name', 'email'], 'eva', Mode::CONTAINS)->fetch();
+
+ self::assertSame([FixtureLoader::AUTHOR_EVA], self::idsOf($authors));
+ }
+
+ public function testSeparateByCallsAreCombinedWithAnd(): void
+ {
+ $this->loadFixtures();
+
+ $authors = (new AuthorQueryObject($this->em))
+ ->by('isActive', true)
+ ->by('rating', 5, Mode::GREATER_OR_EQUAL)
+ ->fetch();
+
+ self::assertSame([FixtureLoader::AUTHOR_ADAM, FixtureLoader::AUTHOR_DAVID], self::idsOf($authors));
+ }
+
+ public function testStringValueIsNotTreatedAsIterable(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by('name', 'abc')->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.name = :by_name', $qb);
+ self::assertSame(['by_name' => 'abc'], self::paramMap($qb));
+ }
+
+ public function testRepeatedFilterOnTheSameColumnUsesUniqueParameterNames(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))
+ ->by('name', 'a', Mode::CONTAINS)
+ ->by('name', 'b', Mode::CONTAINS)
+ ->by('name', 'c', Mode::CONTAINS)
+ ->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.name LIKE :by_name AND e.name LIKE :by_name_2 AND e.name LIKE :by_name_3', $qb);
+ self::assertSame(
+ ['by_name' => '%a%', 'by_name_2' => '%b%', 'by_name_3' => '%c%'],
+ self::paramMap($qb),
+ );
+ }
+
+ public function testRepeatedFilterOnTheSameColumnKeepsAllConditionsEffective(): void
+ {
+ $this->loadFixtures();
+
+ $authors = (new AuthorQueryObject($this->em))
+ ->by('name', 'a', Mode::CONTAINS)
+ ->by('name', 'Nov', Mode::CONTAINS)
+ ->fetch();
+
+ self::assertSame([FixtureLoader::AUTHOR_ADAM, FixtureLoader::AUTHOR_EVA], self::idsOf($authors));
+ }
+
+ public function testRepeatedBetweenFilterReservesBothParameterNames(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))
+ ->by('rating', [1, 2], Mode::BETWEEN)
+ ->by('rating', [3, 4], Mode::BETWEEN)
+ ->createQueryBuilder();
+
+ self::assertDqlContains(
+ 'WHERE (e.rating BETWEEN :by_rating AND :by_rating_2) AND (e.rating BETWEEN :by_rating_3 AND :by_rating_3_2)',
+ $qb,
+ );
+ self::assertSame(
+ ['by_rating' => 1, 'by_rating_2' => 2, 'by_rating_3' => 3, 'by_rating_3_2' => 4],
+ self::paramMap($qb),
+ );
+ }
+
+ public function testEqualsFollowedByBetweenDoesNotCollide(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))
+ ->by('rating', 1)
+ ->by('rating', [3, 4], Mode::BETWEEN)
+ ->createQueryBuilder();
+
+ self::assertDqlContains(
+ 'WHERE e.rating = :by_rating AND (e.rating BETWEEN :by_rating_2 AND :by_rating_2_2)',
+ $qb,
+ );
+ self::assertSame(
+ ['by_rating' => 1, 'by_rating_2' => 3, 'by_rating_2_2' => 4],
+ self::paramMap($qb),
+ );
+ }
+
+ public function testBetweenFollowedByEqualsSkipsTheReservedSecondName(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))
+ ->by('rating', [1, 2], Mode::BETWEEN)
+ ->by('rating', 9)
+ ->createQueryBuilder();
+
+ self::assertDqlContains(
+ 'WHERE (e.rating BETWEEN :by_rating AND :by_rating_2) AND e.rating = :by_rating_3',
+ $qb,
+ );
+ self::assertSame(
+ ['by_rating' => 1, 'by_rating_2' => 2, 'by_rating_3' => 9],
+ self::paramMap($qb),
+ );
+ }
+
+ public function testParameterNamesOfDifferentColumnsDoNotInterfere(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))
+ ->by('name', 'a')
+ ->by('email', 'b')
+ ->by('name', 'c')
+ ->createQueryBuilder();
+
+ self::assertSame(
+ ['by_email' => 'b', 'by_name' => 'a', 'by_name_2' => 'c'],
+ self::paramMap($qb),
+ );
+ }
+
+ public function testDotNotationAddsLeftJoinAndUsesTheJoinedAlias(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by('publisher.country', 'CZ')->createQueryBuilder();
+
+ self::assertDqlSame(
+ 'SELECT e FROM ' . Author::class . ' e LEFT JOIN e.publisher publisher'
+ . ' WHERE publisher.country = :by_publisher_country ORDER BY e.id ASC',
+ $qb,
+ );
+ }
+
+ public function testDotNotationFiltersByJoinedColumn(): void
+ {
+ $this->loadFixtures();
+
+ $authors = (new AuthorQueryObject($this->em))->by('publisher.country', 'CZ')->fetch();
+
+ self::assertSame([FixtureLoader::AUTHOR_ADAM, FixtureLoader::AUTHOR_BEATA], self::idsOf($authors));
+ }
+
+ public function testDeepDotNotationAddsAllIntermediateJoins(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by('books.author.name', 'x')->createQueryBuilder();
+
+ self::assertDqlSame(
+ 'SELECT e FROM ' . Author::class . ' e LEFT JOIN e.books books LEFT JOIN books.author author'
+ . ' WHERE author.name = :by_books_author_name ORDER BY e.id ASC',
+ $qb,
+ );
+ }
+
+ public function testJoinIsAddedOnlyOnceForRepeatedFilters(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))
+ ->by('publisher.country', 'CZ')
+ ->by('publisher.name', 'Alfa')
+ ->createQueryBuilder();
+
+ self::assertSame(1, substr_count(self::normalizeDql($qb->getDQL()), 'LEFT JOIN e.publisher publisher'));
+ }
+
+ public function testJoinedAndPlainColumnsCanBeMixedInOneCall(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by(['name', 'publisher.name'], 'Alfa', Mode::CONTAINS)->createQueryBuilder();
+
+ self::assertDqlSame(
+ 'SELECT e FROM ' . Author::class . ' e LEFT JOIN e.publisher publisher'
+ . ' WHERE e.name LIKE :by_name OR publisher.name LIKE :by_publisher_name ORDER BY e.id ASC',
+ $qb,
+ );
+ }
+
+ public function testJoinedFilterReturnsEachRootEntityOnlyOnce(): void
+ {
+ $this->loadFixtures();
+
+ $authors = (new AuthorQueryObject($this->em))->by('books.title', 'kniha', Mode::CONTAINS)->fetch();
+
+ self::assertSame(
+ [FixtureLoader::AUTHOR_ADAM, FixtureLoader::AUTHOR_BEATA, FixtureLoader::AUTHOR_DAVID],
+ self::idsOf($authors),
+ );
+ }
+
+ public function testMemberOfAcceptsAnEntity(): void
+ {
+ $this->loadFixtures();
+
+ $tag = $this->em->find(Tag::class, FixtureLoader::TAG_PHP);
+ $authors = (new AuthorQueryObject($this->em))->by('tags', $tag, Mode::MEMBER_OF)->fetch();
+
+ self::assertSame([FixtureLoader::AUTHOR_ADAM, FixtureLoader::AUTHOR_EVA], self::idsOf($authors));
+ }
+
+ public function testNotMemberOfAcceptsAnEntity(): void
+ {
+ $this->loadFixtures();
+
+ $tag = $this->em->find(Tag::class, FixtureLoader::TAG_PHP);
+ $authors = (new AuthorQueryObject($this->em))->by('tags', $tag, Mode::NOT_MEMBER_OF)->fetch();
+
+ self::assertSame(
+ [FixtureLoader::AUTHOR_BEATA, FixtureLoader::AUTHOR_CYRIL, FixtureLoader::AUTHOR_DAVID],
+ self::idsOf($authors),
+ );
+ }
+
+ public function testIsEmptyMatchesEntitiesWithoutRelatedRows(): void
+ {
+ $this->loadFixtures();
+
+ $authors = (new AuthorQueryObject($this->em))->by('books', null, Mode::IS_EMPTY)->fetch();
+
+ self::assertSame([FixtureLoader::AUTHOR_CYRIL, FixtureLoader::AUTHOR_EVA], self::idsOf($authors));
+ }
+
+ public function testIsNotEmptyMatchesEntitiesWithRelatedRows(): void
+ {
+ $this->loadFixtures();
+
+ $authors = (new AuthorQueryObject($this->em))->by('books', null, Mode::IS_NOT_EMPTY)->fetch();
+
+ self::assertSame(
+ [FixtureLoader::AUTHOR_ADAM, FixtureLoader::AUTHOR_BEATA, FixtureLoader::AUTHOR_DAVID],
+ self::idsOf($authors),
+ );
+ }
+
+ public function testInArrayWithEmptyArrayMatchesNothing(): void
+ {
+ $this->loadFixtures();
+
+ self::assertSame([], (new AuthorQueryObject($this->em))->by('rating', [], Mode::IN_ARRAY)->fetch());
+ }
+
+ public function testBetweenIsInclusive(): void
+ {
+ $this->loadFixtures();
+
+ $authors = (new AuthorQueryObject($this->em))->by('rating', [3, 7], Mode::BETWEEN)->fetch();
+
+ self::assertSame(
+ [FixtureLoader::AUTHOR_ADAM, FixtureLoader::AUTHOR_BEATA, FixtureLoader::AUTHOR_EVA],
+ self::idsOf($authors),
+ );
+ }
+
+ public function testNotBetweenExcludesNullValues(): void
+ {
+ $this->loadFixtures();
+
+ $authors = (new AuthorQueryObject($this->em))->by('rating', [3, 7], Mode::NOT_BETWEEN)->fetch();
+
+ self::assertSame([FixtureLoader::AUTHOR_DAVID], self::idsOf($authors));
+ }
+
+ public function testDateColumnsCanBeFilteredWithAStringValue(): void
+ {
+ $this->loadFixtures();
+
+ $authors = (new AuthorQueryObject($this->em))->by('birthDate', '1980-01-15')->fetch();
+
+ self::assertSame([FixtureLoader::AUTHOR_ADAM], self::idsOf($authors));
+ }
+
+ public function testDateRangesCanBeFilteredWithStringValues(): void
+ {
+ $this->loadFixtures();
+
+ $authors = (new AuthorQueryObject($this->em))
+ ->by('birthDate', ['1979-01-01', '1991-01-01'], Mode::BETWEEN)
+ ->fetch();
+
+ self::assertSame([FixtureLoader::AUTHOR_ADAM, FixtureLoader::AUTHOR_BEATA], self::idsOf($authors));
+ }
+
+ public function testANullDateIsMatchedByIsNull(): void
+ {
+ $this->loadFixtures();
+
+ $authors = (new AuthorQueryObject($this->em))->by('birthDate', null)->fetch();
+
+ self::assertSame([FixtureLoader::AUTHOR_CYRIL], self::idsOf($authors));
+ }
+
+ public function testADateTimeObjectMatchesADateColumnOnMysql(): void
+ {
+ self::requireMysql();
+ $this->loadFixtures();
+
+ $authors = (new AuthorQueryObject($this->em))
+ ->by('birthDate', new DateTimeImmutable('1980-01-15'))
+ ->fetch();
+
+ self::assertSame([FixtureLoader::AUTHOR_ADAM], self::idsOf($authors));
+ }
+
+ public function testADateTimeObjectMatchesNothingOnSqlite(): void
+ {
+ self::requireSqlite();
+ $this->loadFixtures();
+
+ $authors = (new AuthorQueryObject($this->em))
+ ->by('birthDate', new DateTimeImmutable('1980-01-15'))
+ ->fetch();
+
+ self::assertSame([], $authors);
+ }
+
+ public function testValueIsCapturedWhenByIsCalledNotWhenTheFilterRuns(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $value = 'first';
+
+ $qo->by('name', $value);
+ $value = 'second';
+
+ self::assertSame(['by_name' => 'first'], self::paramMap($qo->createQueryBuilder()));
+ }
+
+ public function testEntityAliasInAColumnNameIsRejected(): void
+ {
+ $qo = (new AuthorQueryObject($this->em))->by('e.name', 'x');
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Do not use entity alias in field names.');
+
+ $qo->createQueryBuilder();
+ }
+
+ public function testEntityAliasIsRejectedEvenAmongValidColumns(): void
+ {
+ $qo = (new AuthorQueryObject($this->em))->by(['name', 'e.email'], 'x');
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Do not use entity alias in field names.');
+
+ $qo->createQueryBuilder();
+ }
+
+ public function testAColumnStartingWithTheAliasNameIsNotRejected(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by('email', 'x')->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.email = :by_email', $qb);
+ }
+
+ public function testAFilterCanBeRegisteredUnderANamedKeyAndDisabled(): void
+ {
+ $this->loadFixtures();
+
+ $qo = (new AuthorQueryObject($this->em))->by('isActive', true, Mode::AUTO, 'my_filter');
+
+ self::assertSame(['my_filter'], $qo->getFilterKeys());
+ self::assertSame(
+ [FixtureLoader::AUTHOR_ADAM, FixtureLoader::AUTHOR_BEATA, FixtureLoader::AUTHOR_DAVID],
+ self::idsOf($qo->fetch()),
+ );
+
+ $qo->disableFilter('my_filter');
+
+ self::assertSame([], $qo->getFilterKeys());
+ self::assertSame([1, 2, 3, 4, 5], self::idsOf($qo->fetch()));
+ }
+
+ public function testANamedFilterKeyIsOverwrittenByASecondCallWithTheSameKey(): void
+ {
+ $this->loadFixtures();
+
+ $qo = (new AuthorQueryObject($this->em))
+ ->by('isActive', true, Mode::AUTO, 'my_filter')
+ ->by('isActive', false, Mode::AUTO, 'my_filter');
+
+ self::assertSame(['my_filter'], $qo->getFilterKeys());
+ self::assertSame([FixtureLoader::AUTHOR_CYRIL, FixtureLoader::AUTHOR_EVA], self::idsOf($qo->fetch()));
+ }
+
+ public function testWithoutAFilterKeyEveryCallAddsANewFilter(): void
+ {
+ $qo = (new AuthorQueryObject($this->em))->by('name', 'a')->by('email', 'b');
+
+ self::assertSame([0, 1], $qo->getFilterKeys());
+ }
+}
diff --git a/tests/QueryObject/ConstructionTest.php b/tests/QueryObject/ConstructionTest.php
new file mode 100644
index 0000000..026dcaa
--- /dev/null
+++ b/tests/QueryObject/ConstructionTest.php
@@ -0,0 +1,200 @@
+em));
+ }
+
+ public function testConstructorThrowsWhenInitDoesNotCallParent(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Always call "parent::init()" when overriding the "init" method.');
+
+ new AuthorQueryObjectWithoutParentInit($this->em);
+ }
+
+ public function testConstructorAppliesDefaultOrder(): void
+ {
+ self::assertTrue((new AuthorQueryObject($this->em))->hasOrder());
+ }
+
+ public function testGetEntityManagerReturnsInjectedInstance(): void
+ {
+ self::assertSame($this->em, (new AuthorQueryObject($this->em))->getEntityManager());
+ }
+
+ public function testSetEntityManagerReplacesInstanceAndReturnsSelf(): void
+ {
+ $other = EntityManagerFactory::create();
+ $qo = new AuthorQueryObject($this->em);
+
+ self::assertSame($qo, $qo->setEntityManager($other));
+ self::assertSame($other, $qo->getEntityManager());
+
+ $other->getConnection()->close();
+ }
+
+ public function testGetEntityClassReturnsMappedEntity(): void
+ {
+ self::assertSame(Author::class, (new AuthorQueryObject($this->em))->getEntityClass());
+ }
+
+ public function testGetDtoClassIsNullByDefault(): void
+ {
+ self::assertNull((new AuthorQueryObject($this->em))->getDTOClass());
+ }
+
+ public function testCreateQueryBuilderProducesSelectAndOrder(): void
+ {
+ self::assertDqlSame(
+ 'SELECT e FROM ' . Author::class . ' e ORDER BY e.id ASC',
+ (new AuthorQueryObject($this->em))->createQueryBuilder(),
+ );
+ }
+
+ public function testCreateQueryBuilderWithoutSelectAndOrderSkipsBoth(): void
+ {
+ self::assertDqlSame(
+ 'SELECT FROM ' . Author::class . ' e',
+ (new AuthorQueryObject($this->em))->createQueryBuilder(false),
+ );
+ }
+
+ public function testCreateQueryBuilderIsIdempotent(): void
+ {
+ $qo = (new AuthorQueryObject($this->em))->by(['name', 'publisher.name'], 'x');
+
+ $first = $qo->createQueryBuilder();
+ $second = $qo->createQueryBuilder();
+
+ self::assertSame($first->getDQL(), $second->getDQL());
+ self::assertSame(self::paramMap($first), self::paramMap($second));
+ self::assertNotSame($first, $second);
+ }
+
+ public function testEntityAliasIsConfigurable(): void
+ {
+ self::assertDqlSame(
+ 'SELECT a FROM ' . Author::class . ' a ORDER BY a.id ASC',
+ (new CustomAliasAuthorQueryObject($this->em))->createQueryBuilder(),
+ );
+ }
+
+ public function testCustomEntityAliasIsUsedByByIdFilter(): void
+ {
+ self::assertDqlContains(
+ 'WHERE a.id IN (:byIdFilter)',
+ (new CustomAliasAuthorQueryObject($this->em))->byId(1)->createQueryBuilder(),
+ );
+ }
+
+ public function testCustomEntityAliasIsUsedByOrByIdFilter(): void
+ {
+ self::assertDqlContains(
+ 'WHERE a.name = :by_name OR a.id IN (:orByIdFilter)',
+ (new CustomAliasAuthorQueryObject($this->em))->by('name', 'x')->orById(3)->createQueryBuilder(),
+ );
+ }
+
+ public function testCustomEntityAliasProducesAnExecutableQuery(): void
+ {
+ $this->loadFixtures();
+
+ self::assertSame(
+ [1],
+ self::idsOf((new CustomAliasAuthorQueryObject($this->em))->byId(1)->fetch()),
+ );
+ }
+
+ public function testCustomEntityAliasWorksForFetchFieldAndCount(): void
+ {
+ $this->loadFixtures();
+
+ $qo = new CustomAliasAuthorQueryObject($this->em);
+
+ self::assertSame(5, $qo->count());
+ self::assertSame(
+ [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5],
+ self::ksorted((new CustomAliasAuthorQueryObject($this->em))->fetchField('id')),
+ );
+ }
+
+ public function testGetQueryUsesGivenQueryBuilder(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $qb = $qo->createQueryBuilder(false)->select('e.id');
+
+ self::assertDqlSame('SELECT e.id FROM ' . Author::class . ' e', $qo->getQuery($qb));
+ }
+
+ public function testGetQueryAppliesScalarHint(): void
+ {
+ $query = (new AuthorQueryObject($this->em))
+ ->addHint(Query::HINT_REFRESH, true)
+ ->getQuery();
+
+ self::assertTrue($query->getHint(Query::HINT_REFRESH));
+ }
+
+ public function testGetQueryResolvesCallableHint(): void
+ {
+ $query = (new AuthorQueryObject($this->em))
+ ->addHint('adt.callableHint', fn() => 'resolved')
+ ->getQuery();
+
+ self::assertSame('resolved', $query->getHint('adt.callableHint'));
+ }
+
+ public function testGetQueryAppliesAllRegisteredHints(): void
+ {
+ $query = (new AuthorQueryObject($this->em))
+ ->addHint(Query::HINT_REFRESH, true)
+ ->addHint('adt.first', 1)
+ ->addHint('adt.second', fn() => 2)
+ ->getQuery();
+
+ self::assertTrue($query->getHint(Query::HINT_REFRESH));
+ self::assertSame(1, $query->getHint('adt.first'));
+ self::assertSame(2, $query->getHint('adt.second'));
+ }
+
+ public function testNoHintsAreSetByDefault(): void
+ {
+ self::assertFalse((new AuthorQueryObject($this->em))->getQuery()->hasHint('adt.callableHint'));
+ }
+
+ public function testGetResultSetReturnsResultSet(): void
+ {
+ self::assertInstanceOf(ResultSet::class, (new AuthorQueryObject($this->em))->getResultSet(1, 10));
+ }
+
+ public function testFluentSettersReturnSameInstance(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+
+ self::assertSame($qo, $qo->byId(1));
+ self::assertSame($qo, $qo->orById(1));
+ self::assertSame($qo, $qo->by('name', 'x'));
+ self::assertSame($qo, $qo->orderBy('id'));
+ self::assertSame($qo, $qo->disableFilter('nope'));
+ self::assertSame($qo, $qo->disableDefaultOrder());
+ self::assertSame($qo, $qo->addPostFetch('books'));
+ }
+}
diff --git a/tests/QueryObject/CountTest.php b/tests/QueryObject/CountTest.php
new file mode 100644
index 0000000..af54894
--- /dev/null
+++ b/tests/QueryObject/CountTest.php
@@ -0,0 +1,73 @@
+em))->callGetCountExpr());
+ }
+
+ public function testOverriddenCountExpressionIsUsed(): void
+ {
+ self::assertSame('DISTINCT e.id', (new DistinctCountAuthorQueryObject($this->em))->callGetCountExpr());
+ }
+
+ public function testCountReturnsAnInt(): void
+ {
+ $this->loadFixtures();
+
+ self::assertSame(5, (new AuthorQueryObject($this->em))->count());
+ }
+
+ public function testCountExpressionCanBeOverridden(): void
+ {
+ $this->loadFixtures();
+
+ self::assertSame(5, (new DistinctCountAuthorQueryObject($this->em))->count());
+ }
+
+ public function testDistinctCountExpressionDeduplicatesJoinedRows(): void
+ {
+ $this->loadFixtures();
+
+ self::assertSame(
+ 3,
+ (new DistinctCountAuthorQueryObject($this->em))
+ ->by('books.title', 'kniha', \ADT\DoctrineComponents\QueryObject\QueryObjectByMode::CONTAINS)
+ ->count(),
+ );
+ }
+
+ public function testCountUsesPaginatorWhenTheQueryIsGrouped(): void
+ {
+ $this->loadFixtures();
+
+ self::assertSame(4, (new GroupedAuthorQueryObject($this->em))->count());
+ }
+
+ public function testGroupedCountRespectsFilters(): void
+ {
+ $this->loadFixtures();
+
+ self::assertSame(2, (new GroupedAuthorQueryObject($this->em))->by('isActive', false)->count());
+ }
+
+ public function testCountIsNotAffectedByTheDefaultOrder(): void
+ {
+ $this->loadFixtures();
+
+ self::assertSame(
+ (new AuthorQueryObject($this->em))->count(),
+ (new AuthorQueryObject($this->em))->disableDefaultOrder()->count(),
+ );
+ }
+}
diff --git a/tests/QueryObject/DtoTest.php b/tests/QueryObject/DtoTest.php
new file mode 100644
index 0000000..db68542
--- /dev/null
+++ b/tests/QueryObject/DtoTest.php
@@ -0,0 +1,98 @@
+loadFixtures();
+ }
+
+ public function testCustomSelectIsUsed(): void
+ {
+ self::assertDqlSame(
+ 'SELECT e.id AS id, e.name AS name FROM ' . Author::class . ' e ORDER BY e.id ASC',
+ (new AuthorNameDtoQueryObject($this->em))->createQueryBuilder(),
+ );
+ }
+
+ public function testFetchReturnsDtoInstances(): void
+ {
+ $result = (new AuthorNameDtoQueryObject($this->em))->fetch();
+
+ self::assertCount(5, $result);
+ self::assertContainsOnlyInstancesOf(AuthorNameDto::class, $result);
+ }
+
+ public function testDtoPropertiesArePopulated(): void
+ {
+ $result = (new AuthorNameDtoQueryObject($this->em))->fetch(1);
+
+ self::assertSame(1, $result[0]->getId());
+ self::assertSame('Adam Novák', $result[0]->getName());
+ }
+
+ public function testTheWholeRowIsPassedToTheConstructor(): void
+ {
+ $result = (new AuthorNameDtoQueryObject($this->em))->fetch(1);
+
+ self::assertSame(['id' => 1, 'name' => 'Adam Novák'], $result[0]->constructorArgument);
+ }
+
+ public function testFiltersAndOrderApplyToDtoQueries(): void
+ {
+ $result = (new AuthorNameDtoQueryObject($this->em))
+ ->by('isActive', false)
+ ->orderBy('name', 'DESC')
+ ->fetch();
+
+ self::assertSame(['Eva Nová', 'Cyril Velký'], array_map(fn($dto) => $dto->getName(), $result));
+ }
+
+ public function testLimitAndOffsetApplyToDtoQueries(): void
+ {
+ $result = (new AuthorNameDtoQueryObject($this->em))->fetch(2, 1);
+
+ self::assertSame([2, 3], array_map(fn($dto) => $dto->getId(), $result));
+ }
+
+ public function testUnknownColumnInTheResultThrows(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Property ' . EmptyDto::class . '::id does not exist.');
+
+ (new AuthorNameDtoQueryObject($this->em))->setDTOClass(EmptyDto::class)->fetch(1);
+ }
+
+ public function testDisablingTheDtoClassFallsBackToRawRows(): void
+ {
+ $result = (new AuthorNameDtoQueryObject($this->em))->setDTOClass(null)->fetch(1);
+
+ self::assertSame([['id' => 1, 'name' => 'Adam Novák']], $result);
+ }
+
+ public function testDtoQueryObjectCanStillBeCounted(): void
+ {
+ self::assertSame(5, (new AuthorNameDtoQueryObject($this->em))->count());
+ }
+
+ public function testFetchOneReturnsADto(): void
+ {
+ $dto = (new AuthorNameDtoQueryObject($this->em))->byId(1)->fetchOne();
+
+ self::assertInstanceOf(AuthorNameDto::class, $dto);
+ self::assertSame('Adam Novák', $dto->getName());
+ }
+}
diff --git a/tests/QueryObject/FetchTest.php b/tests/QueryObject/FetchTest.php
new file mode 100644
index 0000000..fb8eaa6
--- /dev/null
+++ b/tests/QueryObject/FetchTest.php
@@ -0,0 +1,314 @@
+loadFixtures();
+ }
+
+ public function testFetchReturnsAllEntities(): void
+ {
+ $authors = (new AuthorQueryObject($this->em))->fetch();
+
+ self::assertCount(5, $authors);
+ self::assertContainsOnlyInstancesOf(Author::class, $authors);
+ self::assertSame([1, 2, 3, 4, 5], self::idsOf($authors));
+ }
+
+ public function testFetchAppliesLimit(): void
+ {
+ self::assertSame([1, 2], self::idsOf((new AuthorQueryObject($this->em))->fetch(2)));
+ }
+
+ public function testFetchAppliesLimitAndOffset(): void
+ {
+ self::assertSame([3, 4], self::idsOf((new AuthorQueryObject($this->em))->fetch(2, 2)));
+ }
+
+ public function testFetchAppliesOffsetWithoutLimit(): void
+ {
+ self::assertSame([3, 4, 5], self::idsOf((new AuthorQueryObject($this->em))->fetch(null, 2)));
+ }
+
+ public function testZeroLimitIsIgnored(): void
+ {
+ self::assertCount(5, (new AuthorQueryObject($this->em))->fetch(0));
+ }
+
+ public function testZeroOffsetIsIgnored(): void
+ {
+ self::assertSame([1, 2], self::idsOf((new AuthorQueryObject($this->em))->fetch(2, 0)));
+ }
+
+ public function testFetchOnAnEmptyResultReturnsAnEmptyArray(): void
+ {
+ self::assertSame([], (new AuthorQueryObject($this->em))->byId(999)->fetch());
+ }
+
+ public function testFetchWithLockRequiresAnOpenTransaction(): void
+ {
+ $this->expectException(TransactionRequiredException::class);
+
+ (new AuthorQueryObject($this->em))->fetch(null, null, true);
+ }
+
+ public function testFetchWithLockInsideATransaction(): void
+ {
+ $this->em->getConnection()->beginTransaction();
+
+ try {
+ self::assertCount(5, (new AuthorQueryObject($this->em))->fetch(null, null, true));
+ } finally {
+ $this->em->getConnection()->rollBack();
+ }
+ }
+
+ public function testFetchOneReturnsTheSingleEntity(): void
+ {
+ $author = (new AuthorQueryObject($this->em))->byId(FixtureLoader::AUTHOR_ADAM)->fetchOne();
+
+ self::assertInstanceOf(Author::class, $author);
+ self::assertSame('Adam Novák', $author->getName());
+ }
+
+ public function testFetchOneThrowsWhenThereIsNoResult(): void
+ {
+ $this->expectException(NoResultException::class);
+
+ (new AuthorQueryObject($this->em))->byId(999)->fetchOne();
+ }
+
+ public function testFetchOneThrowsWhenThereAreMoreResults(): void
+ {
+ $this->expectException(NonUniqueResultException::class);
+
+ (new AuthorQueryObject($this->em))->fetchOne();
+ }
+
+ public function testFetchOneWithoutStrictReturnsTheFirstResult(): void
+ {
+ self::assertSame('Adam Novák', (new AuthorQueryObject($this->em))->fetchOne(false)->getName());
+ }
+
+ public function testFetchOneWithoutStrictRespectsOrder(): void
+ {
+ self::assertSame(
+ 'Eva Nová',
+ (new AuthorQueryObject($this->em))->orderBy('id', 'DESC')->fetchOne(false)->getName(),
+ );
+ }
+
+ public function testFetchOneOrNullReturnsNullWhenThereIsNoResult(): void
+ {
+ self::assertNull((new AuthorQueryObject($this->em))->byId(999)->fetchOneOrNull());
+ }
+
+ public function testFetchOneOrNullReturnsTheEntity(): void
+ {
+ self::assertSame(
+ 'Cyril Velký',
+ (new AuthorQueryObject($this->em))->byId(FixtureLoader::AUTHOR_CYRIL)->fetchOneOrNull()->getName(),
+ );
+ }
+
+ public function testFetchOneOrNullStillThrowsOnMultipleResults(): void
+ {
+ $this->expectException(NonUniqueResultException::class);
+
+ (new AuthorQueryObject($this->em))->fetchOneOrNull();
+ }
+
+ public function testFetchOneOrNullWithoutStrictReturnsTheFirstResult(): void
+ {
+ self::assertSame('Adam Novák', (new AuthorQueryObject($this->em))->fetchOneOrNull(false)->getName());
+ }
+
+ public function testFetchIterableYieldsEntities(): void
+ {
+ $iterable = (new AuthorQueryObject($this->em))->fetchIterable();
+
+ self::assertInstanceOf(Generator::class, $iterable);
+ self::assertSame([1, 2, 3, 4, 5], self::idsOf(iterator_to_array($iterable)));
+ }
+
+ public function testFetchIterableRejectsModifiedColumns(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Cannot call ADT\DoctrineComponents\QueryObject\QueryObject::fetchIterable on a query object with modified columns.');
+
+ (new ModifiedSelectAuthorQueryObject($this->em))->fetchIterable();
+ }
+
+ public function testFetchIterableAllowsHiddenColumns(): void
+ {
+ self::assertSame(
+ [1, 2, 3, 4, 5],
+ self::idsOf(iterator_to_array((new HiddenSelectAuthorQueryObject($this->em))->fetchIterable())),
+ );
+ }
+
+ public function testFetchPairsMapsKeyToValue(): void
+ {
+ self::assertSame(
+ [
+ 1 => 'Adam Novák',
+ 2 => 'Beata Malá',
+ 3 => 'Cyril Velký',
+ 4 => 'David Adamec',
+ 5 => 'Eva Nová',
+ ],
+ (new AuthorQueryObject($this->em))->fetchPairs('name', 'id'),
+ );
+ }
+
+ public function testFetchPairsWithNullValueReturnsWholeEntities(): void
+ {
+ $pairs = (new AuthorQueryObject($this->em))->fetchPairs(null, 'id');
+
+ self::assertSame([1, 2, 3, 4, 5], array_keys($pairs));
+ self::assertContainsOnlyInstancesOf(Author::class, $pairs);
+ }
+
+ public function testFetchPairsRespectsFiltersAndOrder(): void
+ {
+ $pairs = (new AuthorQueryObject($this->em))
+ ->by('isActive', false)
+ ->orderBy('id', 'DESC')
+ ->fetchPairs('name', 'id');
+
+ self::assertSame([5 => 'Eva Nová', 3 => 'Cyril Velký'], $pairs);
+ }
+
+ public function testFetchPairsRejectsNonScalarKeys(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('The key must not be of type `object`.');
+
+ (new AuthorQueryObject($this->em))->fetchPairs('name', 'birthDate');
+ }
+
+ public function testFetchPairsRequiresAKey(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Parameter "$key" is required, there is nothing to key the result by.');
+
+ (new AuthorQueryObject($this->em))->fetchPairs('name', null);
+ }
+
+ public function testFetchPairsOverlappingKeysKeepTheLastValue(): void
+ {
+ $pairs = (new AuthorQueryObject($this->em))->fetchPairs('name', 'isActive');
+
+ self::assertSame([1 => 'David Adamec', 0 => 'Eva Nová'], $pairs);
+ }
+
+ public function testFetchFieldReturnsAScalarColumnKeyedByItself(): void
+ {
+ self::assertSame(
+ [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5],
+ self::ksorted((new AuthorQueryObject($this->em))->fetchField('id')),
+ );
+ }
+
+ public function testFetchFieldDeduplicatesValues(): void
+ {
+ self::assertSame(
+ [0 => 0, 1 => 1],
+ self::ksorted((new AuthorQueryObject($this->em))->fetchField('isActive')),
+ );
+ }
+
+ public function testFetchFieldKeepsNullUnderAnEmptyKey(): void
+ {
+ $field = (new AuthorQueryObject($this->em))->fetchField('email');
+
+ self::assertArrayHasKey('', $field);
+ self::assertNull($field['']);
+ self::assertCount(5, $field);
+ }
+
+ public function testFetchFieldUsesIdentityForAssociations(): void
+ {
+ self::assertSame(
+ ['' => null, 1 => 1, 2 => 2, 3 => 3],
+ self::ksorted((new AuthorQueryObject($this->em))->fetchField('publisher')),
+ );
+ }
+
+ public function testFetchFieldRespectsFilters(): void
+ {
+ self::assertSame(
+ [3 => 3, 5 => 5],
+ self::ksorted((new AuthorQueryObject($this->em))->by('isActive', false)->fetchField('id')),
+ );
+ }
+
+ public function testFetchFieldIgnoresCustomSelectBecauseItBuildsItsOwnQuery(): void
+ {
+ self::assertSame(
+ [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5],
+ self::ksorted((new ModifiedSelectAuthorQueryObject($this->em))->fetchField('id')),
+ );
+ }
+
+ public function testFetchFieldRejectsSelectColumnsAddedByAFilter(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Cannot call fetchField on a query object with modified columns.');
+
+ (new FilterModifiedSelectAuthorQueryObject($this->em))->fetchField('id');
+ }
+
+ public function testFetchFieldWithLockRequiresAnOpenTransaction(): void
+ {
+ $this->expectException(TransactionRequiredException::class);
+
+ (new AuthorQueryObject($this->em))->fetchField('id', true);
+ }
+
+ public function testCountReturnsTheNumberOfRows(): void
+ {
+ self::assertSame(5, (new AuthorQueryObject($this->em))->count());
+ }
+
+ public function testCountRespectsFilters(): void
+ {
+ self::assertSame(2, (new AuthorQueryObject($this->em))->by('isActive', false)->count());
+ }
+
+ public function testCountIsZeroForAnEmptyResult(): void
+ {
+ self::assertSame(0, (new AuthorQueryObject($this->em))->byId(999)->count());
+ }
+
+ public function testCountIgnoresLimitAndOrder(): void
+ {
+ self::assertSame(5, (new AuthorQueryObject($this->em))->orderBy('name', 'DESC')->count());
+ }
+
+ public function testCountCountsDuplicatedRowsProducedByJoins(): void
+ {
+ self::assertSame(4, (new AuthorQueryObject($this->em))->by('books.title', 'kniha', Mode::CONTAINS)->count());
+ }
+}
diff --git a/tests/QueryObject/FilterTest.php b/tests/QueryObject/FilterTest.php
new file mode 100644
index 0000000..d90c9bd
--- /dev/null
+++ b/tests/QueryObject/FilterTest.php
@@ -0,0 +1,209 @@
+loadFixtures();
+
+ $authors = (new NamedFilterAuthorQueryObject($this->em))->fetch();
+
+ self::assertSame(
+ [FixtureLoader::AUTHOR_ADAM, FixtureLoader::AUTHOR_BEATA, FixtureLoader::AUTHOR_DAVID],
+ self::idsOf($authors),
+ );
+ }
+
+ public function testNamedFiltersKeepTheirKeys(): void
+ {
+ self::assertSame(
+ [AuthorQueryObject::FILTER_ACTIVE, AuthorQueryObject::FILTER_NAMED],
+ (new NamedFilterAuthorQueryObject($this->em))->getFilterKeys(),
+ );
+ }
+
+ public function testDisableFilterRemovesASingleFilter(): void
+ {
+ $this->loadFixtures();
+
+ $authors = (new NamedFilterAuthorQueryObject($this->em))
+ ->disableFilter(AuthorQueryObject::FILTER_ACTIVE)
+ ->fetch();
+
+ self::assertSame([1, 2, 3, 4, 5], self::idsOf($authors));
+ }
+
+ public function testDisableFilterRemovesMultipleFilters(): void
+ {
+ $qo = (new NamedFilterAuthorQueryObject($this->em))
+ ->disableFilter([AuthorQueryObject::FILTER_ACTIVE, AuthorQueryObject::FILTER_NAMED]);
+
+ self::assertSame([], $qo->getFilterKeys());
+ self::assertDqlSame('SELECT e FROM ' . Author::class . ' e ORDER BY e.id ASC', $qo->createQueryBuilder());
+ }
+
+ public function testDisableFilterWithAnUnknownKeyIsANoop(): void
+ {
+ $qo = (new NamedFilterAuthorQueryObject($this->em))->disableFilter('does_not_exist');
+
+ self::assertSame(
+ [AuthorQueryObject::FILTER_ACTIVE, AuthorQueryObject::FILTER_NAMED],
+ $qo->getFilterKeys(),
+ );
+ }
+
+ public function testFiltersAreAppliedInInsertionOrder(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $order = [];
+
+ $qo->addFilter('first', function () use (&$order) {
+ $order[] = 'first';
+ });
+ $qo->addFilter('second', function () use (&$order) {
+ $order[] = 'second';
+ });
+
+ $qo->createQueryBuilder();
+
+ self::assertSame(['first', 'second'], $order);
+ }
+
+ public function testFilterCallbackIsBoundToTheQueryObject(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $boundTo = null;
+
+ $qo->addAnonymousFilter(function () use (&$boundTo) {
+ $boundTo = $this;
+ });
+
+ $qo->createQueryBuilder();
+
+ self::assertSame($qo, $boundTo);
+ }
+
+ public function testFilterReceivesTheQueryBuilder(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $received = null;
+
+ $qo->addAnonymousFilter(function (QueryBuilder $qb) use (&$received) {
+ $received = $qb;
+ });
+
+ $qb = $qo->createQueryBuilder();
+
+ self::assertSame($qb, $received);
+ }
+
+ public function testAFilterCanRegisterAnotherFilter(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $qo->addAnonymousFilter(function () use ($qo) {
+ $qo->by('isActive', true);
+ });
+
+ self::assertDqlContains('WHERE e.isActive = :by_isActive', $qo->createQueryBuilder());
+ }
+
+ public function testAFilterRegisteredByAnotherFilterIsAppliedToResults(): void
+ {
+ $this->loadFixtures();
+
+ $qo = new AuthorQueryObject($this->em);
+ $qo->addAnonymousFilter(function () use ($qo) {
+ $qo->by('isActive', false);
+ });
+
+ self::assertSame([FixtureLoader::AUTHOR_CYRIL, FixtureLoader::AUTHOR_EVA], self::idsOf($qo->fetch()));
+ }
+
+ public function testAFilterReplacingItselfUnderTheSameKeyIsApplied(): void
+ {
+ // líně registrovaný filtr, který teprve za běhu zavolá by() se svým vlastním klíčem,
+ // se v poli filtrů přepíše na pozici, která se právě vykonává
+ $qo = new AuthorQueryObject($this->em);
+ $qo->addFilter(AuthorQueryObject::FILTER_ACTIVE, function () use ($qo) {
+ $qo->by('isActive', true, filterKey: AuthorQueryObject::FILTER_ACTIVE);
+ });
+
+ self::assertDqlContains('WHERE e.isActive = :by_isActive', $qo->createQueryBuilder());
+ }
+
+ public function testAFilterReplacingItselfUnderTheSameKeyIsAppliedToResults(): void
+ {
+ $this->loadFixtures();
+
+ $qo = new AuthorQueryObject($this->em);
+ $qo->addFilter(AuthorQueryObject::FILTER_ACTIVE, function () use ($qo) {
+ $qo->by('isActive', false, filterKey: AuthorQueryObject::FILTER_ACTIVE);
+ });
+
+ self::assertSame([FixtureLoader::AUTHOR_CYRIL, FixtureLoader::AUTHOR_EVA], self::idsOf($qo->fetch()));
+ }
+
+ public function testAFilterReplacingItselfUnderTheSameKeyIsAppliedOnlyOnce(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $qo->addFilter(AuthorQueryObject::FILTER_ACTIVE, function () use ($qo) {
+ $qo->by('isActive', true, filterKey: AuthorQueryObject::FILTER_ACTIVE);
+ });
+
+ $qb = $qo->createQueryBuilder();
+
+ self::assertSame(1, substr_count(self::normalizeDql($qb->getDQL()), 'e.isActive'));
+ }
+
+ public function testAFilterReplacingAnAlreadyAppliedFilterIsApplied(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $qo->addFilter(AuthorQueryObject::FILTER_ACTIVE, function () use ($qo) {
+ $qo->by('isActive', true);
+ });
+ $qo->addFilter(AuthorQueryObject::FILTER_NAMED, function () use ($qo) {
+ $qo->by('name', 'Adam', filterKey: AuthorQueryObject::FILTER_ACTIVE);
+ });
+
+ $qb = $qo->createQueryBuilder();
+
+ // na pořadí podmínek ve WHERE nezáleží, obě ale musí být v query
+ self::assertDqlContains('e.isActive = :by_isActive', $qb);
+ self::assertDqlContains('e.name = :by_name', $qb);
+ self::assertSame(['by_isActive' => true, 'by_name' => 'Adam'], self::paramMap($qb));
+ }
+
+ public function testFiltersRunOnEveryQueryBuilderCreation(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $calls = 0;
+
+ $qo->addAnonymousFilter(function () use (&$calls) {
+ $calls++;
+ });
+
+ $qo->createQueryBuilder();
+ $qo->createQueryBuilder();
+
+ self::assertSame(2, $calls);
+ }
+
+ public function testFiltersAreAppliedEvenWithoutSelectAndOrder(): void
+ {
+ self::assertDqlContains(
+ 'WHERE e.isActive = :by_isActive',
+ (new AuthorQueryObject($this->em))->by('isActive', true)->createQueryBuilder(false),
+ );
+ }
+}
diff --git a/tests/QueryObject/Filters/IsActiveFilterTest.php b/tests/QueryObject/Filters/IsActiveFilterTest.php
new file mode 100644
index 0000000..b82ddcc
--- /dev/null
+++ b/tests/QueryObject/Filters/IsActiveFilterTest.php
@@ -0,0 +1,170 @@
+loadFixtures();
+ }
+
+ public function testConstantValue(): void
+ {
+ self::assertSame('isActiveFilter', IsActiveFilter::IS_ACTIVE_FILTER);
+ }
+
+ public function testTheQueryObjectImplementsTheInterface(): void
+ {
+ self::assertInstanceOf(IsActiveFilter::class, new ActiveAuthorQueryObject($this->em));
+ }
+
+ public function testByIsActiveDefaultsToTrue(): void
+ {
+ self::assertDqlContains(
+ 'WHERE e.isActive = :by_isActive',
+ (new ActiveAuthorQueryObject($this->em))->createQueryBuilder(),
+ );
+ }
+
+ public function testTheDefaultFilterReturnsOnlyActiveRows(): void
+ {
+ self::assertSame(
+ [FixtureLoader::AUTHOR_ADAM, FixtureLoader::AUTHOR_BEATA, FixtureLoader::AUTHOR_DAVID],
+ self::idsOf((new ActiveAuthorQueryObject($this->em))->fetch()),
+ );
+ }
+
+ public function testByIsActiveWithFalseReplacesTheDefaultCondition(): void
+ {
+ $qb = (new ActiveAuthorQueryObject($this->em))->byIsActive(false)->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.isActive = :by_isActive', $qb);
+ self::assertSame(['by_isActive' => false], self::paramMap($qb));
+ }
+
+ public function testByIsActiveWithFalseReturnsTheInactiveRows(): void
+ {
+ self::assertSame(
+ [FixtureLoader::AUTHOR_CYRIL, FixtureLoader::AUTHOR_EVA],
+ self::idsOf((new ActiveAuthorQueryObject($this->em))->byIsActive(false)->fetch()),
+ );
+ }
+
+ public function testByIsActiveReturnsTheQueryObject(): void
+ {
+ $qo = new ActiveAuthorQueryObject($this->em);
+
+ self::assertSame($qo, $qo->byIsActive(false));
+ }
+
+ public function testDisableIsActiveFilterReturnsTheQueryObject(): void
+ {
+ $qo = new ActiveAuthorQueryObject($this->em);
+
+ self::assertSame($qo, $qo->disableIsActiveFilter());
+ }
+
+ public function testByIsActiveRegistersTheFilterUnderTheNamedKey(): void
+ {
+ self::assertSame(
+ [IsActiveFilter::IS_ACTIVE_FILTER],
+ (new ActiveAuthorQueryObject($this->em))->getFilterKeys(),
+ );
+ }
+
+ public function testDisableIsActiveFilterRemovesTheFilter(): void
+ {
+ $qo = (new ActiveAuthorQueryObject($this->em))->disableIsActiveFilter();
+
+ self::assertSame([], $qo->getFilterKeys());
+ self::assertDqlSame(
+ 'SELECT e FROM ' . Author::class . ' e ORDER BY e.id ASC',
+ $qo->createQueryBuilder(),
+ );
+ self::assertSame([1, 2, 3, 4, 5], self::idsOf($qo->fetch()));
+ }
+
+ public function testDisableIsActiveFilterAlsoRemovesAnExplicitByIsActive(): void
+ {
+ $qo = (new ActiveAuthorQueryObject($this->em))->byIsActive(false)->disableIsActiveFilter();
+
+ self::assertSame([], $qo->getFilterKeys());
+ self::assertSame([1, 2, 3, 4, 5], self::idsOf($qo->fetch()));
+ }
+
+ public function testDisableIsActiveFilterIsIdempotent(): void
+ {
+ $qo = (new ActiveAuthorQueryObject($this->em))->disableIsActiveFilter()->disableIsActiveFilter();
+
+ self::assertSame([], $qo->getFilterKeys());
+ }
+
+ public function testALazilyRegisteredFilterAppliesTheCondition(): void
+ {
+ // regrese: byIsActive() se pod klíčem IS_ACTIVE_FILTER nahradí za právě vykonávaný
+ // callback, takže se podmínka nesmí zapomenout přidat do query
+ self::assertDqlContains(
+ 'WHERE e.isActive = :by_isActive',
+ (new LazyActiveAuthorQueryObject($this->em))->createQueryBuilder(),
+ );
+ }
+
+ public function testALazilyRegisteredFilterReturnsOnlyActiveRows(): void
+ {
+ self::assertSame(
+ [FixtureLoader::AUTHOR_ADAM, FixtureLoader::AUTHOR_BEATA, FixtureLoader::AUTHOR_DAVID],
+ self::idsOf((new LazyActiveAuthorQueryObject($this->em))->fetch()),
+ );
+ }
+
+ public function testALazilyRegisteredFilterDoesNotStackConditions(): void
+ {
+ $qb = (new LazyActiveAuthorQueryObject($this->em))->createQueryBuilder();
+
+ self::assertSame(1, substr_count(self::normalizeDql($qb->getDQL()), 'e.isActive'));
+ self::assertSame(['by_isActive' => true], self::paramMap($qb));
+ }
+
+ public function testAnExplicitByIsActiveReplacesTheLazilyRegisteredFilter(): void
+ {
+ $qb = (new LazyActiveAuthorQueryObject($this->em))->byIsActive(false)->createQueryBuilder();
+
+ self::assertSame(1, substr_count(self::normalizeDql($qb->getDQL()), 'e.isActive'));
+ self::assertSame(['by_isActive' => false], self::paramMap($qb));
+ }
+
+ public function testDisableIsActiveFilterRemovesTheLazilyRegisteredFilter(): void
+ {
+ $qo = (new LazyActiveAuthorQueryObject($this->em))->disableIsActiveFilter();
+
+ self::assertSame([], $qo->getFilterKeys());
+ self::assertDqlSame(
+ 'SELECT e FROM ' . Author::class . ' e ORDER BY e.id ASC',
+ $qo->createQueryBuilder(),
+ );
+ self::assertSame([1, 2, 3, 4, 5], self::idsOf($qo->fetch()));
+ }
+
+ public function testRepeatedByIsActiveDoesNotStackConditions(): void
+ {
+ $qb = (new ActiveAuthorQueryObject($this->em))
+ ->byIsActive(false)
+ ->byIsActive(true)
+ ->createQueryBuilder();
+
+ self::assertSame(1, substr_count(self::normalizeDql($qb->getDQL()), 'e.isActive'));
+ self::assertSame(['by_isActive' => true], self::paramMap($qb));
+ }
+}
diff --git a/tests/QueryObject/JoinTest.php b/tests/QueryObject/JoinTest.php
new file mode 100644
index 0000000..f823162
--- /dev/null
+++ b/tests/QueryObject/JoinTest.php
@@ -0,0 +1,259 @@
+em);
+ $qo->addAnonymousFilter(function (QueryBuilder $qb) use ($qo) {
+ $qo->callLeftJoin($qb, 'e.publisher', 'p');
+ });
+
+ self::assertDqlContains('LEFT JOIN e.publisher p', $qo->createQueryBuilder());
+ }
+
+ public function testInnerJoinAddsAnInnerJoin(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $qo->addAnonymousFilter(function (QueryBuilder $qb) use ($qo) {
+ $qo->callInnerJoin($qb, 'e.publisher', 'p');
+ });
+
+ self::assertDqlContains('INNER JOIN e.publisher p', $qo->createQueryBuilder());
+ }
+
+ public function testInnerJoinFiltersOutRowsWithoutRelation(): void
+ {
+ $this->loadFixtures();
+
+ $qo = new AuthorQueryObject($this->em);
+ $qo->addAnonymousFilter(function (QueryBuilder $qb) use ($qo) {
+ $qo->callInnerJoin($qb, 'e.publisher', 'p');
+ });
+
+ self::assertSame(
+ [
+ FixtureLoader::AUTHOR_ADAM,
+ FixtureLoader::AUTHOR_BEATA,
+ FixtureLoader::AUTHOR_CYRIL,
+ FixtureLoader::AUTHOR_EVA,
+ ],
+ self::idsOf($qo->fetch()),
+ );
+ }
+
+ public function testRepeatedManualJoinWithTheSameAliasIsAddedOnlyOnce(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $qo->addAnonymousFilter(function (QueryBuilder $qb) use ($qo) {
+ $qo->callLeftJoin($qb, 'e.publisher', 'p');
+ $qo->callLeftJoin($qb, 'e.publisher', 'p');
+ $qo->callLeftJoin($qb, 'e.publisher', 'p');
+ });
+
+ self::assertSame(1, substr_count(self::normalizeDql($qo->createQueryBuilder()->getDQL()), 'LEFT JOIN e.publisher p'));
+ }
+
+ public function testTheFirstJoinForAnAliasWinsSoASubclassCanRepointIt(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $qo->addAnonymousFilter(function (QueryBuilder $qb) use ($qo) {
+ $qo->callLeftJoin($qb, 'e.publisher', 'x');
+ $qo->callLeftJoin($qb, 'e.books', 'x');
+ });
+
+ $dql = self::normalizeDql($qo->createQueryBuilder()->getDQL());
+
+ self::assertStringContainsString('LEFT JOIN e.publisher x', $dql);
+ self::assertStringNotContainsString('LEFT JOIN e.books x', $dql);
+ }
+
+ public function testAnAliasRegisteredByAFilterAlsoWinsOverDotNotation(): void
+ {
+ $this->loadFixtures();
+
+ $qo = new AuthorQueryObject($this->em);
+ $qo->addFilter('repoint', function (QueryBuilder $qb) use ($qo) {
+ $qo->callLeftJoin($qb, 'e.publisher', 'books');
+ });
+ $qo->by('books.name', 'Alfa');
+
+ $dql = self::normalizeDql($qo->createQueryBuilder()->getDQL());
+
+ self::assertStringContainsString('LEFT JOIN e.publisher books', $dql);
+ self::assertStringNotContainsString('LEFT JOIN e.books books', $dql);
+ self::assertSame([FixtureLoader::AUTHOR_ADAM, FixtureLoader::AUTHOR_BEATA], self::idsOf($qo->fetch()));
+ }
+
+ public function testAManualJoinAndDotNotationOnTheSameRelationAreDeduplicated(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $qo->addAnonymousFilter(function (QueryBuilder $qb) use ($qo) {
+ $qo->callLeftJoin($qb, 'e.publisher', 'publisher');
+ });
+ $qo->by('publisher.country', 'CZ');
+
+ $dql = self::normalizeDql($qo->createQueryBuilder()->getDQL());
+
+ self::assertSame(1, substr_count($dql, 'LEFT JOIN e.publisher publisher'));
+ self::assertStringContainsString('publisher.country = :by_publisher_country', $dql);
+ }
+
+ public function testAJoinWithAConditionIsNotDuplicatedByDotNotation(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $qo->addAnonymousFilter(function (QueryBuilder $qb) use ($qo) {
+ $qo->callLeftJoin($qb, 'e.publisher', 'publisher', Join::WITH, 'publisher.country = \'CZ\'');
+ });
+ $qo->by('publisher.name', 'Alfa');
+
+ $dql = self::normalizeDql($qo->createQueryBuilder()->getDQL());
+
+ self::assertSame(1, substr_count($dql, 'LEFT JOIN e.publisher publisher'));
+ self::assertStringContainsString("WITH publisher.country = 'CZ'", $dql);
+ }
+
+ public function testInnerJoinDoesNotOverrideAnEarlierLeftJoinOnTheSameRelation(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $qo->addAnonymousFilter(function (QueryBuilder $qb) use ($qo) {
+ $qo->callLeftJoin($qb, 'e.publisher', 'p');
+ $qo->callInnerJoin($qb, 'e.publisher', 'p');
+ });
+
+ $dql = self::normalizeDql($qo->createQueryBuilder()->getDQL());
+
+ self::assertStringContainsString('LEFT JOIN e.publisher p', $dql);
+ self::assertStringNotContainsString('INNER JOIN', $dql);
+ }
+
+ public function testManualJoinAcceptsConditionTypeAndCondition(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $qo->addAnonymousFilter(function (QueryBuilder $qb) use ($qo) {
+ $qo->callLeftJoin($qb, 'e.books', 'b', Join::WITH, 'b.price > 100');
+ });
+
+ self::assertDqlContains('LEFT JOIN e.books b WITH b.price > 100', $qo->createQueryBuilder());
+ }
+
+ public function testManualJoinReturnsTheQueryObject(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $returned = null;
+ $qo->addAnonymousFilter(function (QueryBuilder $qb) use ($qo, &$returned) {
+ $returned = $qo->callLeftJoin($qb, 'e.publisher', 'p');
+ });
+
+ $qo->createQueryBuilder();
+
+ self::assertSame($qo, $returned);
+ }
+
+ public function testManualJoinPrefixesAJoinWithoutDot(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $qo->addAnonymousFilter(function (QueryBuilder $qb) use ($qo) {
+ $qo->callLeftJoin($qb, 'publisher', 'p');
+ });
+
+ self::assertDqlContains('LEFT JOIN e.publisher p', $qo->createQueryBuilder());
+ }
+
+ public function testJoinRegistryIsResetForEachQueryBuilder(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $qo->addAnonymousFilter(function (QueryBuilder $qb) use ($qo) {
+ $qo->callLeftJoin($qb, 'e.publisher', 'p');
+ });
+
+ $first = self::normalizeDql($qo->createQueryBuilder()->getDQL());
+ $second = self::normalizeDql($qo->createQueryBuilder()->getDQL());
+
+ self::assertSame($first, $second);
+ self::assertStringContainsString('LEFT JOIN e.publisher p', $second);
+ }
+
+ public function testAddJoinsIgnoresColumnsWithoutDot(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $qb = $qo->createQueryBuilder(false);
+
+ $qo->callAddJoins($qb, ['name', 'email']);
+
+ self::assertDqlSame('SELECT FROM ' . Author::class . ' e', $qb);
+ }
+
+ public function testAddJoinsCreatesOneJoinPerPathSegmentExceptTheLast(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $qb = $qo->createQueryBuilder(false);
+
+ $qo->callAddJoins($qb, ['books.author.publisher.name']);
+
+ self::assertDqlSame(
+ 'SELECT FROM ' . Author::class . ' e LEFT JOIN e.books books'
+ . ' LEFT JOIN books.author author LEFT JOIN author.publisher publisher',
+ $qb,
+ );
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideColumnPrefixes(): array
+ {
+ return [
+ 'plain column' => ['name', 'e.name'],
+ 'already prefixed' => ['e.name', 'e.name'],
+ 'joined alias' => ['publisher.name', 'publisher.name'],
+ 'class name' => ['App\\Entity\\Author', 'App\\Entity\\Author'],
+ 'empty string' => ['', 'e.'],
+ ];
+ }
+
+ #[DataProvider('provideColumnPrefixes')]
+ public function testAddColumnPrefix(string $column, string $expected): void
+ {
+ self::assertSame($expected, (new AuthorQueryObject($this->em))->callAddColumnPrefix($column));
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideJoinedEntityColumnNames(): array
+ {
+ return [
+ 'two segments' => ['e.name', 'e.name'],
+ 'three segments' => ['e.publisher.name', 'publisher.name'],
+ 'four segments' => ['e.books.author.name', 'author.name'],
+ 'single segment' => ['name', 'name'],
+ ];
+ }
+
+ #[DataProvider('provideJoinedEntityColumnNames')]
+ public function testGetJoinedEntityColumnName(string $column, string $expected): void
+ {
+ self::assertSame($expected, (new AuthorQueryObject($this->em))->callGetJoinedEntityColumnName($column));
+ }
+}
diff --git a/tests/QueryObject/OrByIdTest.php b/tests/QueryObject/OrByIdTest.php
new file mode 100644
index 0000000..ac8334d
--- /dev/null
+++ b/tests/QueryObject/OrByIdTest.php
@@ -0,0 +1,177 @@
+loadFixtures();
+
+ $authors = (new AuthorQueryObject($this->em))
+ ->by('isActive', true)
+ ->orById(FixtureLoader::AUTHOR_CYRIL)
+ ->fetch();
+
+ self::assertSame(
+ [
+ FixtureLoader::AUTHOR_ADAM,
+ FixtureLoader::AUTHOR_BEATA,
+ FixtureLoader::AUTHOR_CYRIL,
+ FixtureLoader::AUTHOR_DAVID,
+ ],
+ self::idsOf($authors),
+ );
+ }
+
+ public function testOrByIdProducesOrWhereWithItsOwnParameter(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by('name', 'x')->orById(3)->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.name = :by_name OR e.id IN (:orByIdFilter)', $qb);
+ self::assertSame(['by_name' => 'x', 'orByIdFilter' => [3 => 3]], self::paramMap($qb));
+ }
+
+ public function testOrByIdIsIgnoredWhenThereIsNoOtherCondition(): void
+ {
+ $this->loadFixtures();
+
+ $qb = (new AuthorQueryObject($this->em))->orById(FixtureLoader::AUTHOR_CYRIL)->createQueryBuilder();
+
+ self::assertDqlSame('SELECT e FROM ' . Author::class . ' e ORDER BY e.id ASC', $qb);
+ self::assertSame([], self::paramMap($qb));
+ }
+
+ public function testOrByIdIsCombinedWithById(): void
+ {
+ $this->loadFixtures();
+
+ $qb = (new AuthorQueryObject($this->em))
+ ->byId(FixtureLoader::AUTHOR_ADAM)
+ ->orById(FixtureLoader::AUTHOR_CYRIL)
+ ->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.id IN (:byIdFilter) OR e.id IN (:orByIdFilter)', $qb);
+ self::assertSame(
+ ['byIdFilter' => [1 => 1], 'orByIdFilter' => [3 => 3]],
+ self::paramMap($qb),
+ );
+ self::assertSame(
+ [FixtureLoader::AUTHOR_ADAM, FixtureLoader::AUTHOR_CYRIL],
+ self::idsOf(
+ (new AuthorQueryObject($this->em))
+ ->byId(FixtureLoader::AUTHOR_ADAM)
+ ->orById(FixtureLoader::AUTHOR_CYRIL)
+ ->fetch(),
+ ),
+ );
+ }
+
+ public function testOrByIdIsAppliedAfterAllFiltersAndById(): void
+ {
+ $this->loadFixtures();
+
+ $authors = (new AuthorQueryObject($this->em))
+ ->by('isActive', true)
+ ->byId([FixtureLoader::AUTHOR_ADAM, FixtureLoader::AUTHOR_CYRIL])
+ ->orById(FixtureLoader::AUTHOR_EVA)
+ ->fetch();
+
+ self::assertSame([FixtureLoader::AUTHOR_ADAM, FixtureLoader::AUTHOR_EVA], self::idsOf($authors));
+ }
+
+ public function testArrayOfIdsIsDeduplicated(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by('name', 'x')->orById([3, 4, 3])->createQueryBuilder();
+
+ self::assertSame(['by_name' => 'x', 'orByIdFilter' => [3 => 3, 4 => 4]], self::paramMap($qb));
+ }
+
+ public function testEntityIsAccepted(): void
+ {
+ $this->loadFixtures();
+
+ $cyril = $this->em->find(Author::class, FixtureLoader::AUTHOR_CYRIL);
+
+ $authors = (new AuthorQueryObject($this->em))->by('isActive', true)->orById($cyril)->fetch();
+
+ self::assertContains(FixtureLoader::AUTHOR_CYRIL, self::idsOf($authors));
+ }
+
+ public function testArrayOfEntitiesIsAccepted(): void
+ {
+ $this->loadFixtures();
+
+ $entities = [
+ $this->em->find(Author::class, FixtureLoader::AUTHOR_CYRIL),
+ $this->em->find(Author::class, FixtureLoader::AUTHOR_EVA),
+ ];
+
+ $qb = (new AuthorQueryObject($this->em))->by('isActive', true)->orById($entities)->createQueryBuilder();
+
+ self::assertSame(
+ ['by_isActive' => true, 'orByIdFilter' => [3 => 3, 5 => 5]],
+ self::paramMap($qb),
+ );
+ }
+
+ public function testArrayMixingEntitiesIdsAndNullsIsAccepted(): void
+ {
+ $this->loadFixtures();
+
+ $qb = (new AuthorQueryObject($this->em))
+ ->by('isActive', true)
+ ->orById([$this->em->find(Author::class, FixtureLoader::AUTHOR_CYRIL), null, 5])
+ ->createQueryBuilder();
+
+ self::assertSame(
+ ['by_isActive' => true, 'orByIdFilter' => [3 => 3, 5 => 5]],
+ self::paramMap($qb),
+ );
+ }
+
+ public function testNullIsSkipped(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by('name', 'x')->orById(null)->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.name = :by_name', $qb);
+ self::assertSame(['by_name' => 'x'], self::paramMap($qb));
+ }
+
+ public function testNullInsideAnArrayIsSkipped(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by('name', 'x')->orById([null, 3, null])->createQueryBuilder();
+
+ self::assertSame(['by_name' => 'x', 'orByIdFilter' => [3 => 3]], self::paramMap($qb));
+ }
+
+ public function testArrayOfOnlyNullsIsIgnored(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by('name', 'x')->orById([null])->createQueryBuilder();
+
+ self::assertDqlContains('WHERE e.name = :by_name', $qb);
+ self::assertSame(['by_name' => 'x'], self::paramMap($qb));
+ }
+
+ public function testEmptyArrayIsIgnored(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by('name', 'x')->orById([])->createQueryBuilder();
+
+ self::assertSame(['by_name' => 'x'], self::paramMap($qb));
+ }
+
+ public function testRepeatedCallsAreMerged(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))->by('name', 'x')->orById(3)->orById([4])->createQueryBuilder();
+
+ self::assertSame(1, substr_count(self::normalizeDql($qb->getDQL()), 'e.id IN (:orByIdFilter)'));
+ self::assertSame(['by_name' => 'x', 'orByIdFilter' => [3 => 3, 4 => 4]], self::paramMap($qb));
+ }
+}
diff --git a/tests/QueryObject/OrderByTest.php b/tests/QueryObject/OrderByTest.php
new file mode 100644
index 0000000..a2de537
--- /dev/null
+++ b/tests/QueryObject/OrderByTest.php
@@ -0,0 +1,173 @@
+em))->createQueryBuilder());
+ }
+
+ public function testSingleColumnWithDirection(): void
+ {
+ self::assertDqlContains(
+ 'ORDER BY e.name DESC',
+ (new AuthorQueryObject($this->em))->orderBy('name', 'DESC')->createQueryBuilder(),
+ );
+ }
+
+ public function testSingleColumnWithoutDirection(): void
+ {
+ self::assertDqlContains(
+ 'ORDER BY e.name',
+ (new AuthorQueryObject($this->em))->orderBy('name')->createQueryBuilder(),
+ );
+ }
+
+ public function testArrayOfColumns(): void
+ {
+ self::assertDqlContains(
+ 'ORDER BY e.name ASC, e.id DESC',
+ (new AuthorQueryObject($this->em))->orderBy(['name' => 'ASC', 'id' => 'DESC'])->createQueryBuilder(),
+ );
+ }
+
+ public function testOrderByReplacesThePreviousOrder(): void
+ {
+ self::assertDqlSame(
+ 'SELECT e FROM ' . Author::class . ' e ORDER BY e.email DESC',
+ (new AuthorQueryObject($this->em))->orderBy('name', 'ASC')->orderBy('email', 'DESC')->createQueryBuilder(),
+ );
+ }
+
+ public function testOrderByAddsJoinsForDotNotation(): void
+ {
+ self::assertDqlSame(
+ 'SELECT e FROM ' . Author::class . ' e LEFT JOIN e.publisher publisher ORDER BY publisher.name DESC',
+ (new AuthorQueryObject($this->em))->orderBy('publisher.name', 'DESC')->createQueryBuilder(),
+ );
+ }
+
+ public function testOrderByReusesAJoinAlreadyAddedByAFilter(): void
+ {
+ $qb = (new AuthorQueryObject($this->em))
+ ->by('publisher.country', 'CZ')
+ ->orderBy('publisher.name', 'ASC')
+ ->createQueryBuilder();
+
+ self::assertSame(1, substr_count(self::normalizeDql($qb->getDQL()), 'LEFT JOIN e.publisher publisher'));
+ }
+
+ public function testOrderIsActuallyAppliedToResults(): void
+ {
+ $this->loadFixtures();
+
+ $authors = (new AuthorQueryObject($this->em))->orderBy('name', 'DESC')->fetch();
+
+ self::assertSame(
+ ['Eva Nová', 'David Adamec', 'Cyril Velký', 'Beata Malá', 'Adam Novák'],
+ self::namesOf($authors),
+ );
+ }
+
+ public function testOrderByJoinedColumnIsAppliedToResults(): void
+ {
+ $this->loadFixtures();
+
+ $authors = (new AuthorQueryObject($this->em))
+ ->orderBy(['publisher.name' => 'ASC', 'id' => 'ASC'])
+ ->fetch();
+
+ self::assertSame(
+ [
+ FixtureLoader::AUTHOR_DAVID,
+ FixtureLoader::AUTHOR_ADAM,
+ FixtureLoader::AUTHOR_BEATA,
+ FixtureLoader::AUTHOR_CYRIL,
+ FixtureLoader::AUTHOR_EVA,
+ ],
+ self::idsOf($authors),
+ );
+ }
+
+ public function testDisableDefaultOrderRemovesTheOrderByPart(): void
+ {
+ self::assertDqlSame(
+ 'SELECT e FROM ' . Author::class . ' e',
+ (new AuthorQueryObject($this->em))->disableDefaultOrder()->createQueryBuilder(),
+ );
+ }
+
+ public function testOrderByAfterDisableDefaultOrderWorks(): void
+ {
+ self::assertDqlContains(
+ 'ORDER BY e.name ASC',
+ (new AuthorQueryObject($this->em))->disableDefaultOrder()->orderBy('name', 'ASC')->createQueryBuilder(),
+ );
+ }
+
+ public function testDisableDefaultOrderAfterOrderByRemovesIt(): void
+ {
+ self::assertDqlSame(
+ 'SELECT e FROM ' . Author::class . ' e',
+ (new AuthorQueryObject($this->em))->orderBy('name', 'ASC')->disableDefaultOrder()->createQueryBuilder(),
+ );
+ }
+
+ public function testEntityAliasInFieldNameIsRejected(): void
+ {
+ $qo = (new AuthorQueryObject($this->em))->orderBy('e.name', 'ASC');
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Do not use entity alias in field names.');
+
+ $qo->createQueryBuilder();
+ }
+
+ public function testDirectionMustNotBeGivenTogetherWithAnArray(): void
+ {
+ $qo = (new AuthorQueryObject($this->em))->orderBy(['name' => 'ASC'], 'DESC');
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Do not specify "$order" if "$field" is an array.');
+
+ $qo->createQueryBuilder();
+ }
+
+ public function testEmptyArrayIsRejected(): void
+ {
+ $qo = (new AuthorQueryObject($this->em))->orderBy([]);
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Parameter "$field" cannot be empty.');
+
+ $qo->createQueryBuilder();
+ }
+
+ public function testCustomOrderCallbackReplacesOrderBy(): void
+ {
+ $qo = (new AuthorQueryObject($this->em))->setOrder(function (QueryBuilder $qb) {
+ $qb->addOrderBy('e.rating', 'DESC')->addOrderBy('e.id', 'ASC');
+ });
+
+ self::assertDqlContains('ORDER BY e.rating DESC, e.id ASC', $qo->createQueryBuilder());
+ }
+
+ public function testOrderCallbackIsNotAppliedWithoutSelectAndOrder(): void
+ {
+ self::assertDqlSame(
+ 'SELECT FROM ' . Author::class . ' e',
+ (new AuthorQueryObject($this->em))->orderBy('name', 'ASC')->createQueryBuilder(false),
+ );
+ }
+}
diff --git a/tests/QueryObject/OtherEntitiesTest.php b/tests/QueryObject/OtherEntitiesTest.php
new file mode 100644
index 0000000..3f2158a
--- /dev/null
+++ b/tests/QueryObject/OtherEntitiesTest.php
@@ -0,0 +1,98 @@
+loadFixtures();
+ }
+
+ public function testBookQueryObjectUsesItsOwnEntity(): void
+ {
+ self::assertDqlSame(
+ 'SELECT e FROM ' . Book::class . ' e ORDER BY e.id ASC',
+ (new BookQueryObject($this->em))->createQueryBuilder(),
+ );
+ }
+
+ public function testBooksCanBeFetched(): void
+ {
+ self::assertSame([1, 2, 3, 4, 5], self::idsOf((new BookQueryObject($this->em))->fetch()));
+ }
+
+ public function testBooksCanBeFilteredByTheirManyToOneOwner(): void
+ {
+ $books = (new BookQueryObject($this->em))->by('author.name', 'Adam Novák')->fetch();
+
+ self::assertSame([FixtureLoader::BOOK_ALFA, FixtureLoader::BOOK_BETA], self::idsOf($books));
+ }
+
+ public function testAnOrphanBookIsFoundByIsNull(): void
+ {
+ $books = (new BookQueryObject($this->em))->by('author', null)->fetch();
+
+ self::assertSame([FixtureLoader::BOOK_ORPHAN], self::idsOf($books));
+ }
+
+ public function testBooksCanBeFilteredByPriceRange(): void
+ {
+ $books = (new BookQueryObject($this->em))->by('price', [100, 250], Mode::BETWEEN)->fetch();
+
+ self::assertSame([FixtureLoader::BOOK_ALFA, FixtureLoader::BOOK_BETA], self::idsOf($books));
+ }
+
+ public function testPublisherQueryObjectUsesItsOwnEntity(): void
+ {
+ self::assertDqlSame(
+ 'SELECT e FROM ' . Publisher::class . ' e ORDER BY e.id ASC',
+ (new PublisherQueryObject($this->em))->createQueryBuilder(),
+ );
+ }
+
+ public function testPublishersCanBeFilteredByTheirOneToManyCollection(): void
+ {
+ $publishers = (new PublisherQueryObject($this->em))->by('authors.isActive', true)->fetch();
+
+ self::assertSame([FixtureLoader::PUBLISHER_ALFA], self::idsOf($publishers));
+ }
+
+ public function testPublishersWithoutAuthorsAreFoundByIsEmpty(): void
+ {
+ self::assertSame([], (new PublisherQueryObject($this->em))->by('authors', null, Mode::IS_EMPTY)->fetch());
+ }
+
+ public function testPublisherCountUsesItsOwnCountExpression(): void
+ {
+ self::assertSame(3, (new PublisherQueryObject($this->em))->count());
+ }
+
+ public function testFetchPairsWorksForOtherEntities(): void
+ {
+ self::assertSame(
+ [1 => 'Alfa', 2 => 'Beta', 3 => 'Gama'],
+ (new PublisherQueryObject($this->em))->fetchPairs('name', 'id'),
+ );
+ }
+
+ public function testFetchFieldWorksForOtherEntities(): void
+ {
+ self::assertSame(
+ ['' => null, 'CZ' => 'CZ', 'SK' => 'SK'],
+ self::ksorted((new PublisherQueryObject($this->em))->fetchField('country')),
+ );
+ }
+}
diff --git a/tests/QueryObject/PostFetchTest.php b/tests/QueryObject/PostFetchTest.php
new file mode 100644
index 0000000..3f45cf2
--- /dev/null
+++ b/tests/QueryObject/PostFetchTest.php
@@ -0,0 +1,339 @@
+logger = new SqlLogger([]);
+ $this->em = EntityManagerFactory::create([new LoggingMiddleware($this->logger)]);
+
+ FixtureLoader::load($this->em);
+ $this->em->clear();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->em->getConnection()->close();
+
+ parent::tearDown();
+ }
+
+ public function testAddPostFetchRegistersTheFieldName(): void
+ {
+ $qo = (new AuthorQueryObject($this->em))->addPostFetch('books')->addPostFetch('publisher');
+
+ self::assertSame(['books', 'publisher'], $qo->getPostFetchFields());
+ }
+
+ public function testAddPostFetchKeepsDuplicates(): void
+ {
+ $qo = (new AuthorQueryObject($this->em))->addPostFetch('books')->addPostFetch('books');
+
+ self::assertSame(['books', 'books'], $qo->getPostFetchFields());
+ }
+
+ public function testFetchReturnsAllEntitiesWithPostFetchRegistered(): void
+ {
+ $authors = (new AuthorQueryObject($this->em))->addPostFetch('books')->fetch();
+
+ self::assertSame([1, 2, 3, 4, 5], self::idsOf($authors));
+ }
+
+ public function testPostFetchWithoutRegisteredFieldsIsANoop(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $authors = $qo->fetch();
+
+ $queriesBefore = $this->queryCount();
+ $qo->postFetch(new ArrayIterator($authors));
+
+ self::assertSame($queriesBefore, $this->queryCount());
+ }
+
+ public function testDoPostFetchReturnsEarlyForAnEmptyRootEntityList(): void
+ {
+ $queriesBefore = $this->queryCount();
+
+ QueryObject::doPostFetch($this->em, [], ['books']);
+
+ self::assertSame($queriesBefore, $this->queryCount());
+ }
+
+ public function testDoPostFetchIgnoresValuesThatAreNotEntities(): void
+ {
+ $queriesBefore = $this->queryCount();
+
+ QueryObject::doPostFetch($this->em, [['not an entity']], ['books']);
+
+ self::assertSame($queriesBefore, $this->queryCount());
+ }
+
+ public function testOneToManyCollectionsAreInitialized(): void
+ {
+ $authors = (new AuthorQueryObject($this->em))->addPostFetch('books')->fetch();
+
+ foreach ($authors as $_author) {
+ self::assertTrue(self::isCollectionInitialized($_author, 'books'));
+ }
+ }
+
+ public function testManyToManyCollectionsAreInitialized(): void
+ {
+ $authors = (new AuthorQueryObject($this->em))->addPostFetch('tags')->fetch();
+
+ foreach ($authors as $_author) {
+ self::assertTrue(self::isCollectionInitialized($_author, 'tags'));
+ }
+ }
+
+ public function testPrefetchedOneToManyCollectionsHoldTheCorrectEntities(): void
+ {
+ $authors = (new AuthorQueryObject($this->em))->addPostFetch('books')->fetch();
+
+ self::assertSame(
+ [
+ FixtureLoader::AUTHOR_ADAM => [FixtureLoader::BOOK_ALFA, FixtureLoader::BOOK_BETA],
+ FixtureLoader::AUTHOR_BEATA => [FixtureLoader::BOOK_GAMA],
+ FixtureLoader::AUTHOR_CYRIL => [],
+ FixtureLoader::AUTHOR_DAVID => [FixtureLoader::BOOK_DELTA],
+ FixtureLoader::AUTHOR_EVA => [],
+ ],
+ self::collectionMap($authors, 'getBooks'),
+ );
+ }
+
+ public function testPrefetchedManyToManyCollectionsHoldTheCorrectEntities(): void
+ {
+ $authors = (new AuthorQueryObject($this->em))->addPostFetch('tags')->fetch();
+
+ self::assertSame(
+ [
+ FixtureLoader::AUTHOR_ADAM => [FixtureLoader::TAG_PHP, FixtureLoader::TAG_SQL],
+ FixtureLoader::AUTHOR_BEATA => [FixtureLoader::TAG_SQL],
+ FixtureLoader::AUTHOR_CYRIL => [],
+ FixtureLoader::AUTHOR_DAVID => [FixtureLoader::TAG_JS],
+ FixtureLoader::AUTHOR_EVA => [FixtureLoader::TAG_PHP],
+ ],
+ self::collectionMap($authors, 'getTags'),
+ );
+ }
+
+ public function testPrefetchingOneToManyRemovesTheNPlusOneQueries(): void
+ {
+ $withoutPostFetch = $this->countQueriesWhile(function (): void {
+ foreach ((new AuthorQueryObject($this->em))->fetch() as $_author) {
+ $_author->getBooks()->toArray();
+ }
+ });
+
+ $this->em->clear();
+
+ $withPostFetch = $this->countQueriesWhile(function (): void {
+ foreach ((new AuthorQueryObject($this->em))->addPostFetch('books')->fetch() as $_author) {
+ $_author->getBooks()->toArray();
+ }
+ });
+
+ self::assertSame(6, $withoutPostFetch);
+ self::assertSame(2, $withPostFetch);
+ }
+
+ public function testPrefetchingToOneAssociationsRemovesTheNPlusOneQueries(): void
+ {
+ $withoutPostFetch = $this->countQueriesWhile(function (): void {
+ foreach ((new AuthorQueryObject($this->em))->fetch() as $_author) {
+ $_author->getPublisher()?->getName();
+ }
+ });
+
+ $this->em->clear();
+
+ $withPostFetch = $this->countQueriesWhile(function (): void {
+ foreach ((new AuthorQueryObject($this->em))->addPostFetch('publisher')->fetch() as $_author) {
+ $_author->getPublisher()?->getName();
+ }
+ });
+
+ self::assertSame(4, $withoutPostFetch);
+ self::assertSame(3, $withPostFetch);
+ }
+
+ public function testToOneAssociationsArePrefetchedAsRealEntities(): void
+ {
+ $authors = (new AuthorQueryObject($this->em))->addPostFetch('publisher')->fetch();
+
+ self::assertInstanceOf(Publisher::class, $authors[0]->getPublisher());
+ self::assertSame('Alfa', $authors[0]->getPublisher()->getName());
+ self::assertNull($authors[3]->getPublisher());
+ }
+
+ public function testANestedPathPrefetchesBothLevels(): void
+ {
+ $authors = (new AuthorQueryObject($this->em))->addPostFetch('books.author')->fetch();
+
+ self::assertTrue(self::isCollectionInitialized($authors[0], 'books'));
+ self::assertContainsOnlyInstancesOf(Book::class, $authors[0]->getBooks()->toArray());
+ }
+
+ public function testSeveralFieldsCanBePrefetchedAtOnce(): void
+ {
+ $authors = (new AuthorQueryObject($this->em))
+ ->addPostFetch('books')
+ ->addPostFetch('tags')
+ ->addPostFetch('publisher')
+ ->fetch();
+
+ self::assertTrue(self::isCollectionInitialized($authors[0], 'books'));
+ self::assertTrue(self::isCollectionInitialized($authors[0], 'tags'));
+ self::assertInstanceOf(Publisher::class, $authors[0]->getPublisher());
+ }
+
+ public function testDuplicateFieldNamesArePrefetchedOnlyOnce(): void
+ {
+ $once = $this->countQueriesWhile(function (): void {
+ (new AuthorQueryObject($this->em))->addPostFetch('books')->fetch();
+ });
+
+ $this->em->clear();
+
+ $twice = $this->countQueriesWhile(function (): void {
+ (new AuthorQueryObject($this->em))->addPostFetch('books')->addPostFetch('books')->fetch();
+ });
+
+ self::assertSame($once, $twice);
+ }
+
+ public function testAnUnknownFieldNameThrows(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage("PostFetch: Entita '" . Author::class . "' nemá pole 'thisFieldDoesNotExist'.");
+
+ (new AuthorQueryObject($this->em))->addPostFetch('thisFieldDoesNotExist')->fetch();
+ }
+
+ public function testAnUnknownFieldNamePassedToDoPostFetchThrows(): void
+ {
+ $authors = (new AuthorQueryObject($this->em))->fetch();
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage("PostFetch: Entita '" . Author::class . "' nemá pole 'thisFieldDoesNotExist'.");
+
+ QueryObject::doPostFetch($this->em, $authors, ['thisFieldDoesNotExist']);
+ }
+
+ public function testFetchRunsPostFetchExactlyOnce(): void
+ {
+ CountingPostFetchQueryObject::$calls = 0;
+
+ (new CountingPostFetchQueryObject($this->em))->addPostFetch('books')->fetch();
+
+ self::assertSame(1, CountingPostFetchQueryObject::$calls);
+ }
+
+ public function testFetchOneRunsPostFetchExactlyOnce(): void
+ {
+ CountingPostFetchQueryObject::$calls = 0;
+
+ (new CountingPostFetchQueryObject($this->em))
+ ->addPostFetch('books')
+ ->byId(FixtureLoader::AUTHOR_ADAM)
+ ->fetchOne();
+
+ self::assertSame(1, CountingPostFetchQueryObject::$calls);
+ }
+
+ public function testFetchOneOrNullRunsPostFetchExactlyOnce(): void
+ {
+ CountingPostFetchQueryObject::$calls = 0;
+
+ (new CountingPostFetchQueryObject($this->em))
+ ->addPostFetch('books')
+ ->byId(FixtureLoader::AUTHOR_ADAM)
+ ->fetchOneOrNull();
+
+ self::assertSame(1, CountingPostFetchQueryObject::$calls);
+ }
+
+ public function testFetchOneStillPrefetchesTheCollection(): void
+ {
+ $author = (new AuthorQueryObject($this->em))
+ ->addPostFetch('books')
+ ->byId(FixtureLoader::AUTHOR_ADAM)
+ ->fetchOne();
+
+ self::assertTrue(self::isCollectionInitialized($author, 'books'));
+ }
+
+ public function testLazyLoadingStillWorksWithoutPostFetch(): void
+ {
+ $adam = (new AuthorQueryObject($this->em))->byId(FixtureLoader::AUTHOR_ADAM)->fetchOne();
+
+ self::assertSame(
+ [FixtureLoader::BOOK_ALFA, FixtureLoader::BOOK_BETA],
+ self::idsOf($adam->getBooks()->toArray()),
+ );
+ }
+
+ private function queryCount(): int
+ {
+ return count($this->logger->getQueries());
+ }
+
+ private function countQueriesWhile(callable $callback): int
+ {
+ $before = $this->queryCount();
+ $callback();
+
+ return $this->queryCount() - $before;
+ }
+
+ /**
+ * @param Author[] $authors
+ * @return array
+ */
+ private static function collectionMap(array $authors, string $getter): array
+ {
+ $map = [];
+ foreach ($authors as $_author) {
+ $ids = self::idsOf($_author->$getter()->toArray());
+ sort($ids);
+ $map[(int) $_author->getId()] = $ids;
+ }
+
+ return $map;
+ }
+
+ private static function isCollectionInitialized(Author $author, string $field): bool
+ {
+ $collection = (new ReflectionProperty(Author::class, $field))->getValue($author);
+
+ return $collection instanceof PersistentCollection && $collection->isInitialized();
+ }
+}
diff --git a/tests/QueryObject/ResultSetTest.php b/tests/QueryObject/ResultSetTest.php
new file mode 100644
index 0000000..9bc2818
--- /dev/null
+++ b/tests/QueryObject/ResultSetTest.php
@@ -0,0 +1,161 @@
+loadFixtures();
+ }
+
+ public function testImplementsIteratorAggregate(): void
+ {
+ self::assertInstanceOf(IteratorAggregate::class, (new AuthorQueryObject($this->em))->getResultSet(1, 2));
+ }
+
+ public function testFirstPageReturnsTheFirstItems(): void
+ {
+ $resultSet = (new AuthorQueryObject($this->em))->getResultSet(1, 2);
+
+ self::assertSame([1, 2], self::idsOf(iterator_to_array($resultSet)));
+ }
+
+ public function testSecondPageAppliesTheOffset(): void
+ {
+ $resultSet = (new AuthorQueryObject($this->em))->getResultSet(2, 2);
+
+ self::assertSame([3, 4], self::idsOf(iterator_to_array($resultSet)));
+ }
+
+ public function testLastPageMayBeIncomplete(): void
+ {
+ $resultSet = (new AuthorQueryObject($this->em))->getResultSet(3, 2);
+
+ self::assertSame([5], self::idsOf(iterator_to_array($resultSet)));
+ }
+
+ public function testGetIteratorReturnsAnArrayIterator(): void
+ {
+ self::assertInstanceOf(ArrayIterator::class, (new AuthorQueryObject($this->em))->getResultSet(1, 2)->getIterator());
+ }
+
+ public function testIteratorIsCachedBetweenCalls(): void
+ {
+ $resultSet = (new AuthorQueryObject($this->em))->getResultSet(1, 2);
+
+ self::assertSame($resultSet->getIterator(), $resultSet->getIterator());
+ }
+
+ public function testIteratorIsNotRebuiltWhenTheQueryObjectChanges(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $resultSet = $qo->getResultSet(1, 2);
+
+ self::assertSame([1, 2], self::idsOf(iterator_to_array($resultSet)));
+
+ $qo->byId(999);
+
+ self::assertSame([1, 2], self::idsOf(iterator_to_array($resultSet)));
+ self::assertSame([], $qo->fetch());
+ }
+
+ public function testCountReturnsTheTotalNumberOfRows(): void
+ {
+ self::assertSame(5, (new AuthorQueryObject($this->em))->getResultSet(1, 2)->count());
+ }
+
+ public function testCountIsNotAffectedByThePageSize(): void
+ {
+ self::assertSame(
+ (new AuthorQueryObject($this->em))->getResultSet(1, 2)->count(),
+ (new AuthorQueryObject($this->em))->getResultSet(1, 100)->count(),
+ );
+ }
+
+ public function testCountRespectsFilters(): void
+ {
+ self::assertSame(2, (new AuthorQueryObject($this->em))->by('isActive', false)->getResultSet(1, 2)->count());
+ }
+
+ public function testCountIsCached(): void
+ {
+ $qo = new AuthorQueryObject($this->em);
+ $resultSet = $qo->getResultSet(1, 2);
+
+ self::assertSame(5, $resultSet->count());
+
+ $qo->by('isActive', false);
+
+ self::assertSame(5, $resultSet->count());
+ self::assertSame(2, $qo->count());
+ }
+
+ public function testGetPaginatorIsConfiguredFromTheQuery(): void
+ {
+ $paginator = (new AuthorQueryObject($this->em))->getResultSet(2, 2)->getPaginator();
+
+ self::assertInstanceOf(Paginator::class, $paginator);
+ self::assertSame(5, $paginator->getItemCount());
+ self::assertSame(2, $paginator->getPage());
+ self::assertSame(2, $paginator->getItemsPerPage());
+ self::assertSame(3, $paginator->getPageCount());
+ self::assertSame(1, $paginator->getFirstPage());
+ self::assertSame(3, $paginator->getLastPage());
+ self::assertSame(2, $paginator->getOffset());
+ }
+
+ public function testGetPaginatorIsCached(): void
+ {
+ $resultSet = (new AuthorQueryObject($this->em))->getResultSet(1, 2);
+
+ self::assertSame($resultSet->getPaginator(), $resultSet->getPaginator());
+ }
+
+ public function testPageAboveTheLastOneThrows(): void
+ {
+ $this->expectException(PageIsOutOfRangeException::class);
+ $this->expectExceptionMessage('Page number is out of range. Page number 99 is not in the range (1, 3)');
+
+ (new AuthorQueryObject($this->em))->getResultSet(99, 2)->getPaginator();
+ }
+
+ public function testPageZeroThrows(): void
+ {
+ $this->expectException(PageIsOutOfRangeException::class);
+
+ (new AuthorQueryObject($this->em))->getResultSet(0, 2)->getPaginator();
+ }
+
+ public function testNegativePageThrows(): void
+ {
+ $this->expectException(PageIsOutOfRangeException::class);
+
+ (new AuthorQueryObject($this->em))->getResultSet(-1, 2)->getPaginator();
+ }
+
+ public function testFirstPageOfAnEmptyResultIsValid(): void
+ {
+ $resultSet = (new AuthorQueryObject($this->em))->byId(999)->getResultSet(1, 2);
+
+ self::assertSame(0, $resultSet->count());
+ self::assertSame([], iterator_to_array($resultSet));
+ self::assertSame(1, $resultSet->getPaginator()->getPage());
+ }
+
+ public function testIteratingBeyondTheLastPageReturnsNoRows(): void
+ {
+ self::assertSame([], iterator_to_array((new AuthorQueryObject($this->em))->getResultSet(99, 2)));
+ }
+}
diff --git a/tests/QueryObject/UniqueParamNameTest.php b/tests/QueryObject/UniqueParamNameTest.php
new file mode 100644
index 0000000..5bdab76
--- /dev/null
+++ b/tests/QueryObject/UniqueParamNameTest.php
@@ -0,0 +1,97 @@
+qo = new AuthorQueryObject($this->em);
+ $this->qb = $this->em->createQueryBuilder();
+ }
+
+ public function testUnusedNameIsReturnedUnchanged(): void
+ {
+ self::assertSame('by_name', $this->qo->callGetUniqueParamName($this->qb, 'by_name'));
+ }
+
+ public function testUsedNameGetsASuffix(): void
+ {
+ $this->qb->setParameter('by_name', 'x');
+
+ self::assertSame('by_name_2', $this->qo->callGetUniqueParamName($this->qb, 'by_name'));
+ }
+
+ public function testSuffixIsIncrementedUntilTheNameIsFree(): void
+ {
+ $this->qb->setParameter('by_name', 'a');
+ $this->qb->setParameter('by_name_2', 'b');
+ $this->qb->setParameter('by_name_3', 'c');
+
+ self::assertSame('by_name_4', $this->qo->callGetUniqueParamName($this->qb, 'by_name'));
+ }
+
+ public function testTheLowestFreeSuffixIsUsed(): void
+ {
+ $this->qb->setParameter('by_name', 'a');
+ $this->qb->setParameter('by_name_3', 'c');
+
+ self::assertSame('by_name_2', $this->qo->callGetUniqueParamName($this->qb, 'by_name'));
+ }
+
+ public function testAParameterExplicitlySetToNullStillCountsAsUsed(): void
+ {
+ $this->qb->setParameter('by_name', null);
+
+ self::assertSame('by_name_2', $this->qo->callGetUniqueParamName($this->qb, 'by_name'));
+ }
+
+ public function testTheSecondNameIsOnlyCheckedWhenRequested(): void
+ {
+ $this->qb->setParameter('by_name_2', 'x');
+
+ self::assertSame('by_name', $this->qo->callGetUniqueParamName($this->qb, 'by_name'));
+ self::assertSame('by_name_3', $this->qo->callGetUniqueParamName($this->qb, 'by_name', true));
+ }
+
+ public function testSecondParameterReservationSkipsColidingPairs(): void
+ {
+ $this->qb->setParameter('by_name', 'a');
+ $this->qb->setParameter('by_name_2', 'b');
+
+ self::assertSame('by_name_3', $this->qo->callGetUniqueParamName($this->qb, 'by_name', true));
+ }
+
+ public function testDifferentBaseNamesAreIndependent(): void
+ {
+ $this->qb->setParameter('by_name', 'a');
+
+ self::assertSame('by_email', $this->qo->callGetUniqueParamName($this->qb, 'by_email'));
+ }
+
+ public function testCallIsSideEffectFreeSoRepeatedCallsReturnTheSameName(): void
+ {
+ self::assertSame('by_name', $this->qo->callGetUniqueParamName($this->qb, 'by_name'));
+ self::assertSame('by_name', $this->qo->callGetUniqueParamName($this->qb, 'by_name'));
+ }
+
+ public function testUnrelatedParametersDoNotAffectTheResult(): void
+ {
+ $this->qb->setParameter('byIdFilter', [1]);
+ $this->qb->setParameter('init_isActive', true);
+
+ self::assertSame('by_name', $this->qo->callGetUniqueParamName($this->qb, 'by_name'));
+ }
+}
diff --git a/tests/SqlLoggerTest.php b/tests/SqlLoggerTest.php
new file mode 100644
index 0000000..a21dfd3
--- /dev/null
+++ b/tests/SqlLoggerTest.php
@@ -0,0 +1,165 @@
+getQueries());
+ self::assertSame([], $logger->getParams());
+ self::assertSame(0.0, $logger->getTotalTime());
+ }
+
+ public function testDebugRecordsAQuery(): void
+ {
+ $logger = new SqlLogger([]);
+ $logger->debug('SELECT 1', ['duration' => 0.5]);
+
+ $queries = $logger->getQueries();
+
+ self::assertCount(1, $queries);
+ self::assertSame('SELECT 1', $queries[0]->sql);
+ self::assertSame(0.5, $queries[0]->duration);
+ self::assertSame([], $queries[0]->source);
+ }
+
+ public function testDebugKeepsInsertionOrder(): void
+ {
+ $logger = new SqlLogger([]);
+ $logger->debug('SELECT 1', ['duration' => 0.1]);
+ $logger->debug('SELECT 2', ['duration' => 0.2]);
+ $logger->debug('SELECT 3', ['duration' => 0.3]);
+
+ self::assertSame(
+ ['SELECT 1', 'SELECT 2', 'SELECT 3'],
+ array_map(static fn(object $query) => $query->sql, $logger->getQueries()),
+ );
+ }
+
+ public function testTotalTimeIsTheSumOfDurations(): void
+ {
+ $logger = new SqlLogger([]);
+ $logger->debug('SELECT 1', ['duration' => 0.5]);
+ $logger->debug('SELECT 2', ['duration' => 0.25]);
+
+ self::assertSame(0.75, $logger->getTotalTime());
+ }
+
+ public function testNonDebugLevelsStoreConnectionParams(): void
+ {
+ $logger = new SqlLogger([]);
+ $logger->info('Connecting', ['params' => ['host' => 'localhost', 'password' => '']]);
+
+ self::assertSame(['host' => 'localhost', 'password' => ''], $logger->getParams());
+ self::assertSame([], $logger->getQueries());
+ }
+
+ public function testTheLastConnectionParamsWin(): void
+ {
+ $logger = new SqlLogger([]);
+ $logger->info('Connecting', ['params' => ['host' => 'first']]);
+ $logger->info('Connecting', ['params' => ['host' => 'second']]);
+
+ self::assertSame(['host' => 'second'], $logger->getParams());
+ }
+
+ public function testDebugWithoutDurationRecordsANullDuration(): void
+ {
+ $logger = new SqlLogger([]);
+
+ $isolated = self::runIsolated(static fn() => $logger->debug('SELECT 1'));
+
+ self::assertNull($isolated['throwable']);
+ self::assertNull($logger->getQueries()[0]->duration);
+ self::assertNotSame([], $isolated['errors']);
+ }
+
+ public function testNonDebugLevelWithoutParamsFails(): void
+ {
+ $logger = new SqlLogger([]);
+
+ $isolated = self::runIsolated(static fn() => $logger->info('Connecting'));
+
+ self::assertInstanceOf(TypeError::class, $isolated['throwable']);
+ }
+
+ public function testSourceIsEmptyWhenNoPathsAreConfigured(): void
+ {
+ $logger = new SqlLogger([]);
+ $logger->debug('SELECT 1', ['duration' => 0.1]);
+
+ self::assertSame([], $logger->getQueries()[0]->source);
+ }
+
+ public function testSourceCollectsBacktraceFramesFromConfiguredPaths(): void
+ {
+ $logger = new SqlLogger(['tests' . DIRECTORY_SEPARATOR . 'SqlLoggerTest.php']);
+ $logger->debug('SELECT 1', ['duration' => 0.1]);
+
+ $source = $logger->getQueries()[0]->source;
+
+ self::assertNotSame([], $source);
+ self::assertArrayHasKey('file', $source[0]);
+ self::assertArrayHasKey('line', $source[0]);
+ self::assertStringEndsWith('SqlLoggerTest.php', $source[0]['file']);
+ }
+
+ public function testSourceIgnoresFramesOutsideConfiguredPaths(): void
+ {
+ $logger = new SqlLogger(['this-path-does-not-exist']);
+ $logger->debug('SELECT 1', ['duration' => 0.1]);
+
+ self::assertSame([], $logger->getQueries()[0]->source);
+ }
+
+ public function testSourceSkipsBacktraceFramesWithoutAFileAndLine(): void
+ {
+ $logger = new SqlLogger([basename(__FILE__)]);
+ $values = [3, 1, 2];
+
+ usort($values, static function (int $a, int $b) use ($logger): int {
+ $logger->debug('SELECT 1', ['duration' => 0.1]);
+
+ return $a <=> $b;
+ });
+
+ $source = $logger->getQueries()[0]->source;
+
+ self::assertNotSame([], $source);
+ foreach ($source as $_frame) {
+ self::assertArrayHasKey('file', $_frame);
+ self::assertArrayHasKey('line', $_frame);
+ }
+ }
+
+ public function testSourceCollectsEveryMatchingFrame(): void
+ {
+ $logger = new SqlLogger([basename(__FILE__), basename(__FILE__)]);
+ $logger->debug('SELECT 1', ['duration' => 0.1]);
+
+ self::assertGreaterThanOrEqual(2, count($logger->getQueries()[0]->source));
+ }
+
+ public function testGetSourceCanBeCalledDirectly(): void
+ {
+ self::assertSame([], (new SqlLogger([]))->getSource());
+ }
+}
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 0000000..b51a536
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,112 @@
+getDQL()));
+ }
+
+ protected static function assertDqlContains(string $needle, QueryBuilder|Query $query): void
+ {
+ self::assertStringContainsString($needle, self::normalizeDql($query->getDQL()));
+ }
+
+ /**
+ * @return array
+ */
+ protected static function paramMap(QueryBuilder $qb): array
+ {
+ $params = [];
+ foreach ($qb->getParameters() as $_parameter) {
+ $params[(string) $_parameter->getName()] = $_parameter->getValue();
+ }
+
+ ksort($params);
+
+ return $params;
+ }
+
+ /**
+ * @param object[] $entities
+ * @return int[]
+ */
+ protected static function idsOf(array $entities): array
+ {
+ return array_map(static fn(object $entity) => (int) $entity->getId(), array_values($entities));
+ }
+
+ /**
+ * @param object[] $entities
+ * @return string[]
+ */
+ protected static function namesOf(array $entities): array
+ {
+ return array_map(static fn(object $entity) => $entity->getName(), array_values($entities));
+ }
+
+ /**
+ * @param array $array
+ * @return array
+ */
+ protected static function ksorted(array $array): array
+ {
+ ksort($array, SORT_STRING);
+
+ return $array;
+ }
+
+ /**
+ * @return array{result: mixed, errors: string[], throwable: Throwable|null}
+ */
+ protected static function runIsolated(callable $callback): array
+ {
+ $errors = [];
+ set_error_handler(static function (int $severity, string $message) use (&$errors): bool {
+ $errors[] = $message;
+ return true;
+ });
+
+ $result = null;
+ $throwable = null;
+
+ try {
+ $result = $callback();
+ } catch (Throwable $e) {
+ $throwable = $e;
+ } finally {
+ restore_error_handler();
+ }
+
+ return ['result' => $result, 'errors' => $errors, 'throwable' => $throwable];
+ }
+}
diff --git a/tests/Tracy/QueryPanelTest.php b/tests/Tracy/QueryPanelTest.php
new file mode 100644
index 0000000..8c5aeb5
--- /dev/null
+++ b/tests/Tracy/QueryPanelTest.php
@@ -0,0 +1,110 @@
+getTab();
+
+ self::assertStringContainsString('', $tab);
+ self::assertStringContainsString('tracy-label', $tab);
+ self::assertStringNotContainsString(' q', $tab);
+ self::assertStringNotContainsString('ms', $tab);
+ }
+
+ public function testTabShowsTheQueryCountAndTotalTime(): void
+ {
+ $logger = new SqlLogger([]);
+ $logger->debug('SELECT 1', ['duration' => 0.0123]);
+ $logger->debug('SELECT 2', ['duration' => 0.0002]);
+
+ $tab = (new QueryPanel($logger))->getTab();
+
+ self::assertStringContainsString('2 q', $tab);
+ self::assertStringContainsString("12.5\u{202F}ms", $tab);
+ }
+
+ public function testTabUsesDifferentIconsDependingOnTheQueryCount(): void
+ {
+ $empty = new SqlLogger([]);
+ $withQuery = new SqlLogger([]);
+ $withQuery->debug('SELECT 1', ['duration' => 0.001]);
+
+ self::assertNotSame((new QueryPanel($empty))->getTab(), (new QueryPanel($withQuery))->getTab());
+ }
+
+ public function testPanelWithoutQueriesRendersTheEmptyState(): void
+ {
+ $panel = (new QueryPanel(new SqlLogger([])))->getPanel();
+
+ self::assertStringContainsString('No queries
', $panel);
+ self::assertStringNotContainsString('', $panel);
+ }
+
+ public function testPanelRendersTheQueryTable(): void
+ {
+ $logger = new SqlLogger([]);
+ $logger->debug('SELECT 1 FROM author', ['duration' => 0.0123]);
+
+ $panel = (new QueryPanel($logger))->getPanel();
+
+ self::assertStringContainsString('Queries:', $panel);
+ self::assertStringContainsString('
', $panel);
+ self::assertStringContainsString('SELECT 1 FROM author', $panel);
+ self::assertStringContainsString('12.30', $panel);
+ }
+
+ public function testPanelRendersConnectionParameters(): void
+ {
+ $logger = new SqlLogger([]);
+ $logger->debug('SELECT 1', ['duration' => 0.001]);
+ $logger->info('Connecting', ['params' => ['host' => 'my-database-host', 'password' => '']]);
+
+ $panel = (new QueryPanel($logger))->getPanel();
+
+ self::assertStringContainsString('my-database-host', $panel);
+ }
+
+ public function testPanelRendersSourceLinksWhenSourceIsAvailable(): void
+ {
+ $logger = new SqlLogger([__FILE__]);
+ $logger->debug('SELECT 1', ['duration' => 0.001]);
+
+ $panel = (new QueryPanel($logger))->getPanel();
+
+ self::assertStringContainsString('nettrine-dbal-backtrace', $panel);
+ }
+
+ public function testPanelDoesNotRenderSourceLinksWithoutSource(): void
+ {
+ $logger = new SqlLogger([]);
+ $logger->debug('SELECT 1', ['duration' => 0.001]);
+
+ $panel = (new QueryPanel($logger))->getPanel();
+
+ self::assertStringNotContainsString('nettrine-dbal-backtrace', $panel);
+ }
+
+ public function testPanelDoesNotLeaveAnyOutputBufferOpen(): void
+ {
+ $level = ob_get_level();
+
+ (new QueryPanel(new SqlLogger([])))->getPanel();
+
+ self::assertSame($level, ob_get_level());
+ }
+}