Praca z bazą danych w Laravel - 8 przydatnych porad

Praca z bazą danych w Laravel - 8 przydatnych porad

Laravel słynie z dużej intuicyjności oraz łatwego pisania kodu. Jego twórcy wiele rzeczy zaprojektowali tak, aby możliwie najmocniej uprościć korzystanie z tego frameworka. Nie inaczej jest w przypadku pracy z bazą danych. Przydatne informacje na ten temat znajdziesz w dokumentacji. Natomiast w tym tekście skupię się na aspektach, które nie są tam tak dobrze opisane, a mogą przenieść Twoją pracę z bazą danych na wyższy poziom.

Połączenie z bazą danych w Laravelu

Przed rozpoczęciem pracy nad nowym projektem, należy skonfigurować połączenie z bazą danych w Laravelu. Jak zapewne się domyślacie, jest to bardzo proste. W katalogu głównym projektu w pliku .env wystarczy wpisać dane dostępowe do bazy:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=”your_db_name”
DB_USERNAME=”your_db_user”
DB_PASSWORD=”your_db_pass”

Oczywiście musimy mieć uruchomiony serwer MySQL oraz stworzoną odpowiednią bazę. Czasami jednak, szczególnie jeśli zaczynamy nowy nieduży projekt, warto zamiast MySQL użyć bazy SQLite. W tym celu należy utworzyć plik database/database.sqlite, a następnie w pliku .env powyższy zapis zastąpić tym:

DB_CONNECTION=sqlite

Gdy wszystko jest już skonfigurowane, możemy skupić się na usprawnieniu naszej pracy z bazą. 

1. DB::transaction()

Załóżmy, że mamy aplikację, w której użytkownik może za pomocą bramki płatności wykupić punkty, dzięki którym status jego konta zmieni się z “limited” na “premium”. Na potrzeby tego przykładu przyjmijmy, że dane o rejestracji płatności, punkty oraz status konta trzymane są w trzech oddzielnych tabelach. Nasz kontroler będzie wyglądał mniej więcej tak:

public function store()
{
    Payment::where(['user_id' => auth()->user()->id])->update(['payment_success' => 1]);
    UserPoint::create([
        'user_id' => auth()->user()->id,
        'points' => 1000,
    ]);
    UserStatus::where(['user_id' => auth()->user()->id])->update(['status' => 'premium']);
}

Jednak co się stanie, jeśli z jakiejś przyczyny druga lub trzecia operacja, w której dodajemy punkty, a następnie zmieniamy status, zakończy się niepowodzeniem? Nasza baza danych w Laravelu stanie się niespójna. Z pomocą przychodzi metoda DB::transaction(), dzięki której w przypadku błędu, nasze dane zostaną przywrócone do stanu poprzedniego:

{
    DB::beginTransaction();

    try {
        Payment::where(['user_id' => auth()->user()->id])->update(['payment_success' => 1]);
        UserPoint::create([
            'user_id' => auth()->user()->id,
            'points' => 1000,
        ]);
        UserStatus::where(['user_id' => auth()->user()->id])->update(['status' => 'premium']);

        DB::commit();
    } catch (\Exception $e) {
        DB::rollBack();
    }
}

2. Zapytanie do bazy danych - zastąp warunek if warunkiem when

Bardzo często budując zapytanie do bazy danych za pomocą Eloquent, chcemy kondycyjnie dodać jakiś warunek. Dobrym przykładem będzie pobranie wszystkich użytkowników z bazy lub wszystkich użytkowników ze statusem aktywny/nieaktywny, jeśli URL zawiera parametr “active”.

public function index()
{
    $users = User::orderBy('name', 'ASC');

    if (request()->has('active')) {
        $users->where('active', \request('active'));
    }

    $users->get();
}

Jednak nie wszyscy wiedzą, że ten sam warunek można zapisać beż użycia bloku if, korzystając tylko i wyłącznie z Eloquent:

public function index()
{
    $users = User::orderBy('name', 'ASC')
        ->when(request()->has('active'), function ($query) {
            $query->where('active', request('active'));
        })
        ->get();
}

3. Rozszerzanie modelu o dodatkowe dane

Załóżmy, że w tabeli products przechowujemy dane o produktach w sklepie. W kolumnie price zapisana jest cena produktu w postaci liczby naturalnej w groszach. Zatem produkt, który kosztuje 2EUR,  zapisany jest w postaci 200 (200 centów). Jest to bardzo popularna, ogólnie przyjęta praktyka. Oczywiście użytkownikowi nie będziemy wyświetlać ceny w centach, musimy ją najpierw przekonwertować. Tę operację możemy wykonać za pomocą accessora w Eloquent. Całość jest bardzo dobrze opisana w dokumentacji Laravela.

Jednak co jeśli będziemy chcieli cenę podaną w euro przekonwertować też na inne waluty i dołączyć do kolekcji danych o produkcie, którą zwraca Eloquent? Do tego celu również możemy użyć accessora. Zatem jeśli chcemy, aby informacja o cenie w innych walutach była zawarta w atrybucie prices, musimy stworzyć publiczną metodę getPricesAttribute, a następnie w modelu stworzyć tablicę $appends, zawierającą nazwę atrybutu (w tym przypadku będzie to prices). Oto jak powinna wyglądać całość:

class Product extends Model
{
    protected $appends = ['prices'];

    public function getPricesAttribute()
    {
        return [
            'EUR' => $this->price, 
            'USD' => $this->price, // Use some kind of currency converter here.
            'GBP' => $this->price, // Use some kind of currency converter here.
        ];
    }
}

Ważne: jeśli nazwa atrybutu składa się z dwóch lub więcej słów, np. “converted prices”, nazwę metody zapisujemy zgodnie ze standardem w formacie camel case (getConvertedPricesAttribute), natomiast nazwę w tablicy $appends zapisujemy w formacie snake case (converted_prices).

4. Automatyczne usuwanie relacji w bazie danych Laravel

Najpowszechniejsza relacja w bazie danych to relacja jeden do wielu. Załóżmy, że w tabeli users mamy informacje o użytkowniku, a w tabeli contacts przechowujemy informacje o kontaktach, które użytkownik utworzył na swoim profilu. Tworząc aplikację, zawsze trzeba mieć na uwadze to, co będzie się z nią działo za kilka lat. Zatem powinniśmy zadbać o to, aby w sytuacji gdy użytkownik usunie swoje konto, wszelkie dane z nim powiązane w innych tabelach, również zostały usunięte. W przeciwnym wypadku, po kilku latach baza danych może osiągnąć rozmiary nawet kilkunastu gigabajtów, a to będzie stanowić duży problem. 

Automatyczne usuwanie relacji możemy osiągnąć na kilka sposobów, jednak moim ulubionym jest zdefiniowanie tego już na poziomie migracji. 

Tak wygląda migracja tabeli contacts:

public function up()
{
    Schema::create('contacts', function (Blueprint $table) {
        $table->id();
        $table->unsignedBigInteger('user_id');
        $table->string('fist_name');
        $table->string('last_name');
        $table->string('address');
        $table->string('email');
        $table->timestamps();
    });
}

Kolumna user_id odnosi się oczywiście do id użytkownika, który utworzył kontakt w tabeli contacts. Musimy więc zdefiniować tą relację w migracji, wraz z odpowiednią informacją o tym co zrobić z danymi w przypadku usunięcia rekordu.

public function up()
{
    Schema::create('contacts', function (Blueprint $table) {
        $table->id();
        $table->unsignedBigInteger('user_id');
        $table->string('first_name');
        $table->string('last_name');
        $table->string('address');
        $table->string('email');
        $table->timestamps();

        $table->foreign('user_id')
            ->references('id')
            ->on('users')
            ->onDelete('cascade');
    });
}

5. Używanie map() zamiast foreach

Powyżej wspomniałem o accessorach, dzięki którym możemy modyfikować dane pobrane z bazy. Ta metoda sprawdza się, jeśli za każdym razem chcemy otrzymać dane w zmienionej formie. Co natomiast, jeśli chcemy dokonać modyfikacji tylko w jednym przypadku? Wróćmy do przykładu z produktami, gdzie ceny są zapisane w postaci liczby naturalnej. Załóżmy, że tym razem chcemy zamienić centy na euro oraz dodać atrybut ‘currency’, zawierający informację o walucie. Oczywiście możemy do tego użyć pętli foreach:

class ProductController extends Controller
{
    public function index()
    {
        $products = Product::all();

        foreach ($products as $product) {
            $product->price = $product->price / 100;
            $product->currency = 'EUR';
        }
    }
}

Jednak, jak to bywa w Laravelu, twórcy tego frameworka przygotowali bardziej eleganckie rozwiązanie. Możemy zamiast pętli foreach, użyć metody map():

class ProductController extends Controller
{
    public function index()
    {
        $products = Product::query()->get()->map(function (Product $product) {
            $product->price = $product->price / 100;
            $product->currency = 'EUR';

            return $product;
        });
    }
}

6. Wygodniejsze wyszukiwanie według daty

Zgodnie z konwencją, daty w Laravelu zapisuje się w formacie Y-m-d H:i:s. To pozwala na wygodne wyszukiwanie rekordów w określonym przedziale czasowym, przy pomocy metod whereDate, whereMonth, whereDay, whereYear lub whereTime. Zobaczcie poniższe przykłady:

$products = Product::whereDate('created_at', '2018-01-31')->get();
$products = Product::whereMonth('created_at', '12')->get();
$products = Product::whereDay('created_at', '31')->get();
$products = Product::whereYear('created_at', date('Y'))->get();
$products = Product::whereTime('created_at', '=', ’14:13:58')->get();

7. N+1 problem

Tworzenie relacji między tabelami jest w Laravelu niezwykle proste. Rozwiązania zastosowane w tym frameworku mają wiele zalet, jednak nie są pozbawione wad. Wróćmy do przykładu przedstawionego wcześniej z relacją jeden do wielu, gdzie do jednego użytkownika jest przypisane wiele kontaktów. Zdefiniowanie relacji odbywa się w ten sposób:

class User extends Authenticatable
{
    public function contacts()
    {
        return $this->hasMany(Contact::class);
    }
}

Następnie załóżmy, że chcemy pobrać wszystkich użytkowników oraz wyświetlić kontakty powiązane za pomocą relacji:

public function index()
{
    $users = User::all();

    foreach ($users as $user) {
        echo $user->name;

        foreach ($user->contacts as $contact) {
            echo $contact->last_name;
        }
    }
}

Niestety, to co ułatwia nam pracę z bazą danych dzięki Eloquent, powoduje też, że nie do końca wiemy, co tak naprawdę dzieje się “pod przykryciem”. Całe szczęście z pomocą przychodzi kolejne znakomite narzędzie Laravela czyli Telescope. Dzięki niemu możemy zobaczyć dokładnie, jak wygląda w tym przypadku zapytanie do bazy danych: 

Sprawdzanie, jak dokładnie wygląda zapytanie do bazy danych Laravel przy pomocy narzędzia Telescope

Jak widać, Laravel najpierw pobiera wszystkich użytkowników z tabeli users, a następnie dla każdego użytkownika z osobna wykonuje zapytanie do tabeli contacts. Jest to poważny problem pod kątem wydajności. W przypadku większej liczby użytkowników, będziemy mieli całą masę niepotrzebnych zapytań do bazy. Na szczęście rozwiązanie jest bardzo proste. Musimy zamienić metodę all() na with(), w celu zadeklarowania, że chcemy, aby nasze wyniki zostały pobrane od razu z danymi znajdującymi się w kolumnie ‘contacts’.

public function index()
{
    $users = User::with('contacts')->get();

    foreach ($users as $user) {
        echo $user->name;

        foreach ($user->contacts as $contact) {
            echo $contact->last_name;
        }
    }
}

8. Sortowanie danych w relacji

Dla ostatniego przykładu, wróćmy jeszcze raz do użytkownika oraz jego kontaktów. Warto byłoby wyświetlać kontakty posortowane alfabetycznie. Oczywiście możemy to zrobić w ten sposób:

$users = User::with(['contacts' => function ($query) {
    $query->orderBy('last_name', 'ASC');
}])->get();

Jednak śmiało możemy założyć, że właściwie za każdym razem kiedy będziemy pobierać te dane, będzie potrzeba posortowania ich właśnie w ten sposób. W takim razie, co zrobić, aby nie trzeba było powtarzać tej czynności? Spójrzmy jeszcze raz, jak wygląda definicja relacji w modelu:

public function contacts()
{
    return $this->hasMany(Contact::class);
}

Tutaj właśnie należy dodać metodę orderBy, aby nasze wyniki za każdym razem były posortowane w odpowiedni sposób:

public function contacts()
{
    return $this->hasMany(Contact::class)
        ->orderBy('last_name' ,'ASC');
}

Bądź na bieżąco z Laravelem

Laravel słynie z wielu wygodnych rozwiązań. Dlatego warto nieustannie poszerzać swoją wiedzę i poznawać coraz to nowe techniki, które ułatwią pracę z tym systemem. Kolejne wersje tego frameworka wydawane są systematycznie, często przynosząc całą masę nowych funkcjonalności. Jesteśmy z tym w Droptica na bieżąco, realizując usługi PHP. 

3. Najlepsze praktyki zespołów programistycznych