KdybyDoctrine – ako zefektívniť DQL

Čamo
Člen | 786
+
0
-

Zdravím,
dnes som robil jeden datagrid napojený na Doktrínu a keď som ho dorobil, začal som rozmýšľať ako to DQL v datasource zefektívniť. Ide o to, že tam mám väzbu 1:N medzi článkom a jazkovými mutáciami, takže som použil join. To samozrejme vo výsledku SQL vygeneruje duplicitné riadky kôli tým jazykom. A pokiaľ viem to je pri Doktríne problém pri hydratácii. Chcel by som vedieť ako sa to dá riešiť. Kód pre datagrid vyzerá takto:

		$grid = new DataGrid();

		$grid->setDataSource( $this->articleRepository->createQueryBuilder()
			->select( 'article', 'user', 'status', 'langs' )
			->from( 'App\Model\Entity\Article', 'article' )
			->innerJoin( 'article.status', 'status' )
			->innerJoin( 'article.user', 'user' )
			->innerJoin( 'article.langs', 'langs' )
			->whereCriteria( ['status.id =' => [Entity\Status::STATUS_DRAFT, Entity\Status::STATUS_PUBLISHED, Entity\Status::STATUS_UNPUBLISHED]] )
		);

		$grid->addColumnText( 'id', 'ID' );

		$grid->addColumnText( 'title', 'Title', 'langs.title' )
			->setRenderer( function ( $article ) {
				return $article->lang->getTitle();  // Calls $article->getLang( $code = 'sk' )
			})
			->setSortable()
			->setFilterText()
			->setSplitWordsSearch( FALSE );

Pri title sa volá metóda $article->getLang(), ktorá vracia jeden riadok z kolekcie $article->langs a je implementovaná cez filter

class Article extends Nette\Object
{
	//...

	public function getLang( $code = 'sk' )
	{
		return $this->langs->filter( function ( $item ) use ( $code ) {
			return $item->getCode() == $code;
		})->first();
	}

Skúšal som to vypodmienkovať v DQL, aby mi vracalo pre langs iba jeden riadok, ale doktrína to nejako obyšla…

Oli
Člen | 1215
+
+1
-

Ocramius pise o tom problemu tady: http://ocramius.github.io/…n-hydration/

Čamo
Člen | 786
+
0
-

Díky prečítam si.
Ešte ray díky, prečítal som si to. Veľmi elegantné riešenie.

Editoval Čamo (23. 7. 2017 20:44)

Čamo
Člen | 786
+
0
-

Hmmmm, podarilo sa mi napísať metódu, ktorá urobí multi-step hydration, ale nedá sa to použiť v Datagride, pretože ten pracuje s inštanciou QueryBuildera. Takže Datagrid nezaujíma, že treba vykonať nejaké partial query… Ale v aplikácii pri ručnom renderovaní sa to bude dať dúfam použiť.

	public function findBy( $by )
	{
		$by_langs = [];
		$by_others = [];
		foreach ( $by as $key => $value )
		{
			// Because in first QB langs key is undefined and on the contrary in second QB other keys are undefined
			// E.g. langs.title = throws exception 'langs' is not defined in first QB.
			if ( strpos( $key, 'langs' ) === 0 ) $by_langs[$key] = $value;
			elseif ( strpos( $key, 'id' ) === 0 || strpos( $key, 'article.id' ) === 0 ) $by_others[$key] = $by_langs[$key] = $value;  // id is in both conditions.
			else $by_others[$key] = $value;
		}

		$articles = $this->articleRepository->createQueryBuilder()
			->select( 'article', 'user', 'status' )
			->from( 'App\Model\Entity\Article', 'article' )
			->innerJoin( 'article.status', 'status' )
			->innerJoin( 'article.user', 'user' )
			->whereCriteria( $by_others )
			->getQuery();

		// Next result is discarded. This is just re-hydrating the collections.
		$this->articleRepository->createQueryBuilder()
			->select( 'partial article.{id}', 'langs' )
			->from( 'App\Model\Entity\Article', 'article' )
			->innerJoin( 'article.langs', 'langs' )
			->whereCriteria( $by_langs )
			->getQuery()
			->setHint( Query::HINT_FORCE_PARTIAL_LOAD, 1 )
			->getResult();

		// Returns ResultSet because of paginator.
		return new Kdyby\Doctrine\ResultSet( $articles );
	}

Edit: https://forum.nette.org/…ektivnit-dql#…

Editoval Čamo (26. 7. 2017 10:10)

cubic
Člen | 45
+
0
-

Řešil jsem něco podobného minulý týden. Shop, 60.000 objednávek, 4 joiny, Ublaboo/DataGrid, na každé vykreslení potřebné 2 SQL dotazy po 4 sekundách – byly to subquery, nad kterými se prováděl count() a stránkování.
Na MariaDB 10.1 to běželo celkem bez problémů (vyřadila z query všechny zbytečné joiny), přecházel jsem ale na Perconu, která ty dotazy podle explain zpracovávala úplně jinak (ta tam joiny nechala a ještě vytvořila 2 temp tabulky navíc).
Nakonec se mi osvědčilo vyhodit z QueryBuilderu všechny joiny, přidal jsem je do setCondition, jen pokud je zadaná hodnota filtru. Pokud není, tak lazy loading a několik desítek dotazů navíc. I tak je grid vygenerovaný za zlomek původního času.
Strávil jsem tím několik hodin, úplně elegantní to není, ale funguje. Pokud tam těch stránek nemáš tisíce, tak Ti to ale asi nepomůže :-)

Čamo
Člen | 786
+
0
-

cubic
Takže 60 000 a štyri 1:N väzby robia takýto problém? Netušíš čím to je? Lebo tam by sa mal robiť jeden count() a potom max nejakých 50 záznamov/page nahydratovať cez tie joiny. Koľko tie väzby vytvoria približne duplicít v SQL resulte?

newPOPE
Člen | 648
+
0
-

@Čamo co tak vyhodit tie duplicity uz pomocou SQL (group by, distinct, native query)?

Čamo
Člen | 786
+
0
-

newPOPE
Môžeš byť trochu konkrétnejší? Ja som to skúšal cez podmienku, ale už som písal, že doktrína to obišla. A native query to som ešte neskúšal a radšej by som sa tomu vyhol.

cubic
Člen | 45
+
0
-

@Čamo
Tohle je podobný jednodušší případ – výchozí entita + k tomu 2 joiny. Přes QueryBuilder mám nadefinovaný ten subquery jako data source, DataGrid z toho pak vygeneruje uvedený SQL dotaz pro stránkování + distinct pro vytažení ID záznamů.

Jinak MariaDB asi lépe optimalizuje subquery (= pročístí je od nepotřebných věcí), ale je těžké ty verze porovnat, každý si jede svoje.
V jiné situaci mi to vyšlo v Perconě na 15 tabulek (vč. 2 temporary derived, celkem přes 4 sekundy), zatímco úplně stejný dotaz v MariaDB 7 tabulek (1× temporary derived, čas cca 0,5s), koukal jsem na to jak blázen.

SELECT COUNT(id_68)
FROM (
SELECT o0_.id AS id_68, o0_.created AS created_2, i1_.id AS id_136, i1_.invoice_id AS invoice_id_137,
i1_.symbol AS symbol_138, i1_.date_created AS date_created_139, i1_.name AS name_143,
i1_.surname AS surname_144, i1_.company AS company_145, i1_.street AS street_146, i1_.city AS
city_147
FROM orders o0_
INNER JOIN order_state o2_ ON o0_.order_state_id = o2_.id
LEFT JOIN invoice i1_ ON o0_.id = i1_.order_id
WHERE o0_.visible = 1) dctrn_result
ORDER BY created_2 DESC
LIMIT 10
OFFSET 0

Editoval cubic (24. 7. 2017 22:07)

newPOPE
Člen | 648
+
+1
-

@Čamo nie som isty co berie ako datasource ten grid ale pokial chces mat vysledky rychlo tak DQL ti v tomto bude povacsine klast klacky pod nohy.

Optimalnejsie v danych situaciach je proste pouzit native query, namapovat to na nejake ViewEntity (ak ich vobec potrebujes) ktore su len na citanie a tie zobrazovat.

Nieco ale myslim ide aj cez operator NEW v DQL konkretne 4ty example.

Čamo
Člen | 786
+
0
-

Díky za nápady. Dnes som ešte zistil, že sa dá do joinu pridať AND cez \Doctrine\ORM\Query\Expr\Join::WITH , takže to nakoniec dokáže odstrániť duplicity. Akurát, že mi pribudol jeden innerjoin navyšše.

public function findByForDatagrid( array $by )
{
	return $this->articleRepository->createQueryBuilder()
		->select( 'article', 'user', 'status', 'langs' )
		->from( 'App\Model\Entity\Article', 'article' )
		->innerJoin( 'article.status', 'status' )
		->innerJoin( 'article.user', 'user' )
		->innerJoin( 'article.langs', 'langs' )
		->innerJoin( 'langs.lang', 'lang', \Doctrine\ORM\Query\Expr\Join::WITH, 'lang.id = :lang' )
		->whereCriteria( $by )
		->setParameter( 'lang', Entity\Lang::SK );
}

Inak tiež som použil Ublaboo datagrid…

Editoval Čamo (25. 7. 2017 13:58)

Čamo
Člen | 786
+
0
-

Teraz ma ešte napadlo, že tá moja multi-step hydration metóda nieje dobre napísaná. Ak sa nemýlim, tak všetky tie väzby a podmienky musia byť rovnaké pre obidve query. Rozdiel má byť iba v tom, čo je v metóde select() tj. čo sa bude hydratovať…

A ešte to nezahŕňa problém so stránkovaním. Ak si dobre pamätám, tak F. Procházka to rieši, cez postFetch event v query objektoch. Doktrína resp. QueryBuilder to ale nepodporuje, že ano?

Vie mi to niekto upresniť? Tiež by nebol odveci nejaký komplexný príklad na tú multistep-hydration so stránkovaním.

	public function findBy( $by )
	{
		$articles = $this->articleRepository->createQueryBuilder()
			->select( 'article', 'user', 'status' )
			->from( 'App\Model\Entity\Article', 'article' )
			->innerJoin( 'article.status', 'status' )
			->innerJoin( 'article.user', 'user' )
			->innerJoin( 'article.langs', 'langs' )
			->whereCriteria( $by )
			->getQuery();

		// Next result is discarded. This is just re-hydrating the collections.
		$articles = $this->articleRepository->createQueryBuilder()
	->select( 'partial article.{id}', 'langs' )
			->from( 'App\Model\Entity\Article', 'article' )
			->innerJoin( 'article.status', 'status' )
			->innerJoin( 'article.user', 'user' )
			->innerJoin( 'article.langs', 'langs' )
			->whereCriteria( $by )
			->getQuery();

		// Returns ResultSet because of paginator.
		return new Kdyby\Doctrine\ResultSet( $articles );
	}

Editoval Čamo (26. 7. 2017 10:12)

newPOPE
Člen | 648
+
0
-

@Čamo :D nie ze by som nechcel aby sa ti to podarilo. Ale ked to narves a zabalis do sluzby ktora pusti native query s custom mappingom mas vyhrate (a bude to rychle) za chvilu. Ak mas Ublaboo tak tam jasne pisu, ze staci implementovat Object that implements IDataSource a je.

Čamo
Člen | 786
+
0
-

newPOPE
Hlavný problém je že som neni taký skiller. Tieto knižnice používam, aby mi uľahčili a urýchlili písanie kódu. Písať vlastné entity a objekty mi pripadá ako pravý opak. Ja si viem napísať a „vyhydratovať“ SQL dotaz aj cez 8 tabuliek, ale nechcem skončiť s modelom, čo má 2000 riadkov. Preto sa tomu bránim.

Editoval Čamo (26. 7. 2017 9:56)

Čamo
Člen | 786
+
0
-

Nešlo by urobť, že by mal konštruktor new Kdyby\Doctrine\ResultSet( $articles ) ako druhý parameter callback ktorý by sa spustil na postFetch()… Len neviem ako by sa ten kód v callbacku nastránkoval…

EDIT: Nastránkoval by sa proste automaticky cez query, ktoré by ten callback vrátil. A musel by to byť array calbackov…

Editoval Čamo (26. 7. 2017 11:07)