Eloquent pod pokličkou: has()

Když skládáme dotaz pomocí Eloquentu, můžeme využít metodu has(), která zkontroluje jestli existuje vazba oproti modelu, se kterým pracujeme. V tomto článku se proto na ni zaměříme, detailněji zjistíme jak pracuje na databázové úrovni a proč ji využívat.

Mějme jednoduchý příklad:

Pohled pak může vypadat například takto:

<ul>
    @foreach ($users as $user) {
        @if ($user->tasks->count())
            <li>
                {{ $user->name }} ({{ $user->tasks->count() }})
            </li>
        @endif
    @endforeach
</ul>

I když tento příklad bude fungovat správně, znamená to však, že budeme vytahovat z databáze více dat než potřebujeme. Prvně totiž musíme provést ještě kontrolu/filtrování našeho výsledku před tím, než s ním můžeme dále pracovat. Jak si umíte jistě dále představit, pokud bychom tuto funkcionalitu potřebovali použít na dalších místech, stává se kód hůře udržovatelný a musíme všude zajistit stejnou konzistenci dat.

Jako řešení by se mohlo zdát zapouzdření této logiky a filtrování do samostatné funkce, takže bychom dosáhli snadné znovupoužitelnosti:

// App/User.php

public static function filterThoseWithTasks($users)
{
    return $users->filter(function ($user) {
        return $user->tasks->count() > 0;
    });
}

Použitím této metody nemusíme využívat logiku v pohledu a tím pádem filtrace může být použita v celé aplikaci.

Co kdybychom ale jednoduše změnili náš databázový dotaz aby přímo vybíral pouze uživatele, kteří mají nějaký úkol...

Následující kód ukazuje, jak můžeme využít metodu has() a tím upravit náš databázový dotaz, aby získal pouze uživatele s přidělenými úkoly.

User::has('tasks')->get();

Pokud bychom vykonali předchozí kód, získali bychom následující dotaz:

SELECT *
FROM "users"
WHERE EXISTS
    (SELECT *
     FROM "tasks"
     WHERE "users"."id" = "tasks"."user_id")

Všimněte si přítomnosti klauzule WHERE v dotazu, který by nebyl přítomen pokud bychom jednoduše chtěli získat všechny uživatele použitím User::all(). Když se podíváme na přidaný poddotaz, vidíme že jsou vybrány všechny úkoly kde se user_id úkolu shoduje s id uživatele z vnějšího dotazu. Vidíme také klíčové slovo EXISTS, což znamená, že je vybrán pouze uživatel, pokud existuje jeden nebo více úkolů v tabulce tasks s user_id patřící danému uživateli. Ve výsledku je tedy mnohem výhodnější filtrovat dataset než výsledek procházet v PHP.

Aktuálně se zaměříme pouze na první tři. První je samozřejmě název vztahu u kterého budeme hledat existenci, tak jako jsme udělali v předchozím příkladě. Na další dva se teď zaměříme více.

Nyní se podívejme na SQL dotaz, který se vykoná po úpravě:

SELECT *
FROM "users"
WHERE
    (SELECT count(*)
     FROM "tasks"
     WHERE "users"."id" = "tasks"."user_id") < 10

Můžeme vidět, že se náš poddotaz mírně změnil a namísto toho využívá SQL funkce COUNT k počtu úkolů, které mají shodné user_id s daným uživatelem. Také si můžeme všimnout, že jsme specifikovali parametr $operator, tedy jak se má hodnota v $count porovnávat. To nám tak dává flexibilitu definovat scénář jak by vazba k úkolům měla existovat a podle toho jaké uživatelské záznamy vrátit.

Důležité je si zde všimnout, že naše WHERE podmínka nyní využívá standardního porovnávání oproti počtu, namísto využití EXISTS. V případě našeho prvního příkladu byl Eloquent natolik chytrý, že si uvědomil že nás nezajímá kolik úkolů existuje, prostě ho zajímalo pouze jestli nějaký úkol existuje. Díky tomu bylo v dotazu použito klíčové slovo EXISTS, jelikož tato metoda těží z trochu vyšší rychlosti v porovnání s funkcí COUNT. Nebudu moc zabíhat do detailu ohledně výkonu a jaké jsou výhody užití EXISTS, nicméně v následující tabulce je ukázka rychlosti dotazu na základě našich předešlých příkladů. Ve zkratce MySQL  zastaví proces EXISTS ihned jakmile nalezne alespoň jednu shodu, zatímco COUNT potřebuje projít všechny záznamy.

| METODA | DOTAZ                                                                                                  | CAS     |
|--------|--------------------------------------------------------------------------------------------------------|---------|
| EXISTS | SELECT * FROM "users" WHERE exists (SELECT * FROM "tasks" WHERE "users"."id" = "tasks"."user_id")      | 2.150ms |
| COUNT  | SELECT * FROM "users" WHERE (SELECT count(*) FROM "tasks" WHERE "users"."id" = "tasks"."user_id") < 10 | 9.010ms |

Samozřejmě, je to velmi jednoduchý příklad a vytahuje se pouze 50 uživatelů s 10 úkoly, avšak už teď můžeme vidět velké rozdíly.

public function doesntHave($relation, $boolean = 'and', Closure $callback = null)
{
    return $this->has($relation, '<', 1, $boolean, $callback);
}
public function whereHas($relation, Closure $callback = null, $operator = '>=', $count = 1)
{
    return $this->has($relation, $operator, $count, 'and', $callback);
}
public function orWhereHas($relation, Closure $callback, $operator = '>=', $count = 1)
{
    return $this->has($relation, $operator, $count, 'or', $callback);
}
public function whereDoesntHave($relation, Closure $callback = null)
{
    return $this->doesntHave($relation, 'and', $callback);
}