From 0d88a53584f124f26507bcaeee064e0da3c17f96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Konvi=C4=8Dka?= Date: Thu, 30 Jul 2026 12:58:43 +0200 Subject: [PATCH 1/4] Fix param name collision in QueryObject::by() --- src/QueryObject/QueryObject.php | 34 ++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/src/QueryObject/QueryObject.php b/src/QueryObject/QueryObject.php index 1598732..468416b 100644 --- a/src/QueryObject/QueryObject.php +++ b/src/QueryObject/QueryObject.php @@ -214,10 +214,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'; } } @@ -330,6 +332,32 @@ function($_column) use ($qb, $value, $mode) { 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 From dd886faadd218988e4e131646716699abc7ea443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Konvi=C4=8Dka?= Date: Fri, 31 Jul 2026 13:51:00 +0200 Subject: [PATCH 2/4] Fix QueryObject bugs found while writing the test suite --- README.md | 8 + composer.json | 3 +- docs/fixes.md | 275 ++++++++++++++++++ docs/postfetch-fix.md | 215 ++++++++++++++ .../Filters/IsActiveFilterTrait.php | 4 +- src/QueryObject/QueryObject.php | 180 ++++++------ src/QueryObject/QueryObjectInterface.php | 2 +- 7 files changed, 591 insertions(+), 96 deletions(-) create mode 100644 docs/fixes.md create mode 100644 docs/postfetch-fix.md diff --git a/README.md b/README.md index dfdb4e4..2fb7af3 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: diff --git a/composer.json b/composer.json index 2d74df1..e480cf1 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,8 @@ }, "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", 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/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 468416b..a54faa8 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); @@ -329,6 +327,13 @@ 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; } @@ -376,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)); @@ -402,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.'); @@ -416,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.'); } } @@ -460,18 +464,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); @@ -507,13 +511,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; } } @@ -534,27 +535,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; } /********* @@ -631,8 +629,6 @@ final public function fetchOne(bool $strict = true, bool $lock = false): object throw new NonUniqueResultException(); } - $this->postFetch(new ArrayIterator($result)); - return $result[0]; } @@ -655,6 +651,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)}(); @@ -680,10 +680,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); @@ -694,7 +694,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; @@ -752,7 +752,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 @@ -792,7 +792,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; } @@ -817,7 +817,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); @@ -828,7 +828,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); @@ -850,7 +850,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."); @@ -861,7 +861,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 @@ -883,13 +883,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) @@ -906,14 +906,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 @@ -931,10 +928,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; From 989b7c67913d16864cbd82e98a47b74da886be47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Konvi=C4=8Dka?= Date: Fri, 31 Jul 2026 13:51:38 +0200 Subject: [PATCH 3/4] Add test suite and CI --- .gitattributes | 5 + .github/workflows/tests.yml | 148 +++++++ .gitignore | 7 +- README.md | 33 ++ composer.json | 16 + phpunit.xml.dist | 37 ++ tests/BaseListenerTest.php | 356 +++++++++++++++ tests/DI/DbalExtensionTest.php | 170 +++++++ tests/DatabaseTestCase.php | 35 ++ tests/Entities/IdentifierTest.php | 82 ++++ tests/EntityManagerTest.php | 269 +++++++++++ tests/Fixtures/Dto/AuthorNameDto.php | 29 ++ tests/Fixtures/Dto/EmptyDto.php | 12 + tests/Fixtures/Entity/Author.php | 155 +++++++ tests/Fixtures/Entity/Book.php | 80 ++++ tests/Fixtures/Entity/Publisher.php | 53 +++ tests/Fixtures/Entity/Tag.php | 43 ++ tests/Fixtures/EntityManagerFactory.php | 144 ++++++ tests/Fixtures/FixtureLoader.php | 114 +++++ .../Fixtures/Listener/IncompleteListener.php | 16 + tests/Fixtures/Listener/RecordingListener.php | 109 +++++ tests/Fixtures/PublisherMarker.php | 9 + .../QueryObject/ActiveAuthorQueryObject.php | 20 + .../QueryObject/AuthorNameDtoQueryObject.php | 29 ++ .../QueryObject/AuthorQueryObject.php | 108 +++++ .../AuthorQueryObjectWithoutParentInit.php | 12 + .../Fixtures/QueryObject/BookQueryObject.php | 24 + .../CountingPostFetchQueryObject.php | 19 + .../CustomAliasAuthorQueryObject.php | 10 + .../DistinctCountAuthorQueryObject.php | 13 + .../FilterModifiedSelectAuthorQueryObject.php | 19 + .../QueryObject/GroupedAuthorQueryObject.php | 19 + .../HiddenSelectAuthorQueryObject.php | 22 + .../ModifiedSelectAuthorQueryObject.php | 17 + .../NamedFilterAuthorQueryObject.php | 25 ++ .../QueryObject/PublisherQueryObject.php | 24 + tests/Fixtures/RecordingBar.php | 21 + tests/Logging/LoggingMiddlewareTest.php | 233 ++++++++++ tests/Logging/StatementTest.php | 147 ++++++ tests/QueryObject/ByIdTest.php | 214 +++++++++ tests/QueryObject/ByModeTest.php | 167 +++++++ tests/QueryObject/ByTest.php | 419 ++++++++++++++++++ tests/QueryObject/ConstructionTest.php | 200 +++++++++ tests/QueryObject/CountTest.php | 73 +++ tests/QueryObject/DtoTest.php | 98 ++++ tests/QueryObject/FetchTest.php | 314 +++++++++++++ tests/QueryObject/FilterTest.php | 155 +++++++ .../Filters/IsActiveFilterTest.php | 123 +++++ tests/QueryObject/JoinTest.php | 259 +++++++++++ tests/QueryObject/OrByIdTest.php | 177 ++++++++ tests/QueryObject/OrderByTest.php | 173 ++++++++ tests/QueryObject/OtherEntitiesTest.php | 98 ++++ tests/QueryObject/PostFetchTest.php | 339 ++++++++++++++ tests/QueryObject/ResultSetTest.php | 161 +++++++ tests/QueryObject/UniqueParamNameTest.php | 97 ++++ tests/SqlLoggerTest.php | 165 +++++++ tests/TestCase.php | 112 +++++ tests/Tracy/QueryPanelTest.php | 110 +++++ 58 files changed, 6137 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/tests.yml create mode 100644 phpunit.xml.dist create mode 100644 tests/BaseListenerTest.php create mode 100644 tests/DI/DbalExtensionTest.php create mode 100644 tests/DatabaseTestCase.php create mode 100644 tests/Entities/IdentifierTest.php create mode 100644 tests/EntityManagerTest.php create mode 100644 tests/Fixtures/Dto/AuthorNameDto.php create mode 100644 tests/Fixtures/Dto/EmptyDto.php create mode 100644 tests/Fixtures/Entity/Author.php create mode 100644 tests/Fixtures/Entity/Book.php create mode 100644 tests/Fixtures/Entity/Publisher.php create mode 100644 tests/Fixtures/Entity/Tag.php create mode 100644 tests/Fixtures/EntityManagerFactory.php create mode 100644 tests/Fixtures/FixtureLoader.php create mode 100644 tests/Fixtures/Listener/IncompleteListener.php create mode 100644 tests/Fixtures/Listener/RecordingListener.php create mode 100644 tests/Fixtures/PublisherMarker.php create mode 100644 tests/Fixtures/QueryObject/ActiveAuthorQueryObject.php create mode 100644 tests/Fixtures/QueryObject/AuthorNameDtoQueryObject.php create mode 100644 tests/Fixtures/QueryObject/AuthorQueryObject.php create mode 100644 tests/Fixtures/QueryObject/AuthorQueryObjectWithoutParentInit.php create mode 100644 tests/Fixtures/QueryObject/BookQueryObject.php create mode 100644 tests/Fixtures/QueryObject/CountingPostFetchQueryObject.php create mode 100644 tests/Fixtures/QueryObject/CustomAliasAuthorQueryObject.php create mode 100644 tests/Fixtures/QueryObject/DistinctCountAuthorQueryObject.php create mode 100644 tests/Fixtures/QueryObject/FilterModifiedSelectAuthorQueryObject.php create mode 100644 tests/Fixtures/QueryObject/GroupedAuthorQueryObject.php create mode 100644 tests/Fixtures/QueryObject/HiddenSelectAuthorQueryObject.php create mode 100644 tests/Fixtures/QueryObject/ModifiedSelectAuthorQueryObject.php create mode 100644 tests/Fixtures/QueryObject/NamedFilterAuthorQueryObject.php create mode 100644 tests/Fixtures/QueryObject/PublisherQueryObject.php create mode 100644 tests/Fixtures/RecordingBar.php create mode 100644 tests/Logging/LoggingMiddlewareTest.php create mode 100644 tests/Logging/StatementTest.php create mode 100644 tests/QueryObject/ByIdTest.php create mode 100644 tests/QueryObject/ByModeTest.php create mode 100644 tests/QueryObject/ByTest.php create mode 100644 tests/QueryObject/ConstructionTest.php create mode 100644 tests/QueryObject/CountTest.php create mode 100644 tests/QueryObject/DtoTest.php create mode 100644 tests/QueryObject/FetchTest.php create mode 100644 tests/QueryObject/FilterTest.php create mode 100644 tests/QueryObject/Filters/IsActiveFilterTest.php create mode 100644 tests/QueryObject/JoinTest.php create mode 100644 tests/QueryObject/OrByIdTest.php create mode 100644 tests/QueryObject/OrderByTest.php create mode 100644 tests/QueryObject/OtherEntitiesTest.php create mode 100644 tests/QueryObject/PostFetchTest.php create mode 100644 tests/QueryObject/ResultSetTest.php create mode 100644 tests/QueryObject/UniqueParamNameTest.php create mode 100644 tests/SqlLoggerTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Tracy/QueryPanelTest.php 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 2fb7af3..3af0c5e 100644 --- a/README.md +++ b/README.md @@ -365,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 e480cf1..62c0464 100644 --- a/composer.json +++ b/composer.json @@ -25,9 +25,25 @@ "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/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/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/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..d15e0c9 --- /dev/null +++ b/tests/QueryObject/FilterTest.php @@ -0,0 +1,155 @@ +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 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..b8d2f45 --- /dev/null +++ b/tests/QueryObject/Filters/IsActiveFilterTest.php @@ -0,0 +1,123 @@ +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 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()); + } +} From b9f64678483b6fd308299cb253d7e264ff743cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Konvi=C4=8Dka?= Date: Mon, 3 Aug 2026 19:09:39 +0200 Subject: [PATCH 4/4] Fix lazily registered filters being skipped in createQueryBuilder() --- src/QueryObject/QueryObject.php | 25 +++++++-- .../LazyActiveAuthorQueryObject.php | 25 +++++++++ tests/QueryObject/FilterTest.php | 54 +++++++++++++++++++ .../Filters/IsActiveFilterTest.php | 47 ++++++++++++++++ 4 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 tests/Fixtures/QueryObject/LazyActiveAuthorQueryObject.php diff --git a/src/QueryObject/QueryObject.php b/src/QueryObject/QueryObject.php index a54faa8..0134f58 100644 --- a/src/QueryObject/QueryObject.php +++ b/src/QueryObject/QueryObject.php @@ -451,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) { 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/QueryObject/FilterTest.php b/tests/QueryObject/FilterTest.php index d15e0c9..d90c9bd 100644 --- a/tests/QueryObject/FilterTest.php +++ b/tests/QueryObject/FilterTest.php @@ -130,6 +130,60 @@ public function testAFilterRegisteredByAnotherFilterIsAppliedToResults(): void 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); diff --git a/tests/QueryObject/Filters/IsActiveFilterTest.php b/tests/QueryObject/Filters/IsActiveFilterTest.php index b8d2f45..b82ddcc 100644 --- a/tests/QueryObject/Filters/IsActiveFilterTest.php +++ b/tests/QueryObject/Filters/IsActiveFilterTest.php @@ -9,6 +9,7 @@ use ADT\DoctrineComponents\Tests\Fixtures\Entity\Author; use ADT\DoctrineComponents\Tests\Fixtures\FixtureLoader; use ADT\DoctrineComponents\Tests\Fixtures\QueryObject\ActiveAuthorQueryObject; +use ADT\DoctrineComponents\Tests\Fixtures\QueryObject\LazyActiveAuthorQueryObject; final class IsActiveFilterTest extends DatabaseTestCase { @@ -110,6 +111,52 @@ public function testDisableIsActiveFilterIsIdempotent(): void 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))