Poznajemy polimorficzne odpowiedniki wcześniej poznanych relacji jeden do jednego oraz jeden do wielu. Kontynuujemy zgłębianie zagadnień bazodanowych, ORMa Laravela oraz Query Buildera i fasady DB.

Tworzymy polimorficzną relację jeden do jednego – kupiec, kupujący, dane kontaktowe

Będziemy mieli modele dla kupca i sprzedającego. Każdy z nich będzie w relacji jeden do jednego z modelem danych kontaktowych. W relacji niepolimorficznej musielibyśmy tworzyć osobno model danych kontaktowych kupca, model danych kontaktowych sprzedającego i między nimi relacje tworzyć.

W relacji polimorficznej osiągniemy to samo za pomocą jednego modelu dane kontaktowe, który można potem podłączyć do dowolnej ilości modeli.

Tworzymy model Buyer wraz z migracją, fabryką i seederem:

php artisan make:model Buyer -mfs  

Przechodzimy do migracji i wypełniamy pola, jakie chcemy mieć w naszym modelu:

//buyer migration
return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('buyers', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
            $table->string('name');
            $table->integer('age');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('buyers');
    }
};

Wykonujemy migrację:

php artisan migrate

Tabela 'buyers’ z polami ID, timestampami oraz polami 'name’ i 'age’ jest już poprawnie utworzona w bazie danych. Pora utworzyć model Seller – z migracją, fabryką i seederem:

php artisan make:model Seller -mfs 

Migracja sprzedającego będzie wyglądać podobnie:

//seller migration
return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('sellers', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
            $table->string('name');
            $table->integer('age');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('sellers');
    }
};

Migrujemy i tworzymy model Contact wraz z migracją:

php artisan migrate
php artisan make:model Contact -m  

Wypełniamy pola jakie ma mieć nasz kontakt plus tworzymy interfejs 'contactable’, którego będziemy używać do tworzenia relacji z innymi modelami:

//contact migration
return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('contacts', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
            $table->string('city')->nullable(false);
            $table->string('street')->nullable(false);
            $table->morphs('contactable');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('contacts');
    }
};

Ten interfejs będzie posiadać (w bazie danych) pola 'contactable_type’ oraz 'contactable_id’, poza polami 'city’, 'street’, id oraz timestampami. Dzięki temu można tam przechować zarówno typ (np. kupujący albo sprzedający albo inny model) jak i id modelu.

Na razie jednak odpowiednio wszystko połączmy. Po wykonaniu migracji idziemy do modelu Buyer wypełnić fillable i stworzyć relację z kontaktem:

class Buyer extends Model
{
    use HasFactory;
    protected $fillable = ['name', 'age'];
    public function contact()
    {
        return $this->morphOne('App\Models\Contact', 'contactable');
    }
}

Metoda ->morphOne() to polimorficzny odpowiednik hasOne(). Podajemy namespace modelu, który mamy posiadać, oraz interfejsu. To samo wykonujemy w modelu sprzedającego:

class Seller extends Model
{
    use HasFactory;
    protected $fillable = ['name', 'age'];
    public function contact()
    {
        return $this->morphOne('App\Models\Contact', 'contactable');
    }
}

Sprzedający również posiada jeden kontakt. Ale jest to relacja polimorficzna, kontakty są dzielone między modelami – to jest model jest dzielony. Nie mamy modelów BuyerContact ani SellerContact – mamy jeden generyczny.

Cały czas jednak zarówno jeden Buyer może posiadać jeden kontakt i odwrotnie jak i jeden Seller może posiadać jeden kontakt i odwrotnie. Kontakt jest w tym wypadku dzieckiem dla modelu rodzica i musimy jeszcze w jego modelu poza fillable zaznaczyć polimorficzny odpowiednik ->belongsTo:

class Contact extends Model
{
    use HasFactory;
    protected $fillable = ['city', 'street'];
    public function contactable()
    {
        return $this->morphTo();
    }
}

W ->belongsTo() podawaliśmy, do jakiej klasy modelu nasz model należy, ale tutaj nie musimy – kontakt nie wie, do czego zostanie przypięty, do sprzedającego, kupującego albo jeszcze innego modelu.

Wie tylko, że znajduje się w relacji polimorficznej 1 do 1 jako model-dziecko.

Tworzymy fabryki i seedery dla naszych kupców i sprzedających

To będzie bardzo powtarzalna robota. Po pierwsze, definiujemy fabrykę kupującego:

class BuyerFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'name' => fake()->name(),
            'age' => random_int(18,30)
        ];
    }
}

Po drugie, definiujemy fabrykę sprzedającego:

class SellerFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'name' => fake()->name(),
            'age' => random_int(18,30)
        ];
    }
}

Następnie, pamiętając o imporcie modelu, ustalamy seeder dla kupującego:

use App\Models\Buyer;
class BuyerSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        Buyer::factory()
            ->count(10)
            ->create();
    }
}

W podobny sposób ustalamy seeder sprzedającego:

use App\Models\Seller;
class SellerSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        Seller::factory()
            ->count(10)
            ->create();
    }
}

Teraz wykonujemy seeding komendami db:seed z flagą zawierającą nazwę klasy każdego seedera:

php artisan db:seed --class=BuyerSeeder;
php artisan db:seed --class=SellerSeeder;

Gotowe – w bazie danych powinniśmy już widzieć fejkowe rekordy.

Podstawowe operacje na naszych modelach – piszemy komendy

Musimy powoli zwiększać nasze umiejętności bazo-danowe oraz ORMowe. Dlatego oprócz naszych modeli, w console.php zaimportujemy sobie także fasadę DB:

(...)
use Illuminate\Support\Facades\DB;
(...)
use App\Models\Buyer;
use App\Models\Seller;
use App\Models\Contact;

Tworzymy komendę logującą ilość sprzedawców w bazie danych:

Artisan::command('buyercount', function () {
    $cnt = Buyer::count();
    $this->comment("There are $cnt buyers in database");
});

Teraz ta sama komenda, ale z użyciem fasady DB. Nasza tabela nazywa się 'buyers’, zaś komenda wygląda tak:

Artisan::command('buyercount2', function () {
    $cnt = DB::table('buyers')->count();
    $this->comment("There are $cnt buyers in database");
});

Analogicznie tworzymy komendę logującą ilość sprzedawców:

Artisan::command('sellercount', function () {
    $cnt = Seller::count();
    $this->comment("There are $cnt sellers in database");
});

Teraz za pomocą fasady DB, którą lepiej poznamy w następnych odcinkach tutoriala:

Artisan::command('sellercount2', function () {
    $cnt = DB::table('sellers')->count();
    $this->comment("There are $cnt sellers in database");
});

Teraz komenda logująca ilość kupujących i sprzedających posiadających dane kontaktowe:

Artisan::command('hascontact', function () {
    $bcnt = Buyer::has('contact')->count();
    $scnt = Seller::has('contact')->count();
    $this->comment("There are $bcnt buyers with contact");
    $this->comment("There are $scnt sellers with contact");
});

Teraz analogicznie komenda pokazująca tych, którzy danych kontaktowych nie posiadają. Moglibyśmy bawić się w odejmowanie od liczby wszystkich tych, którzy posiadają, ale warto chyba teraz poznać metodę doesnthave:

Artisan::command('hasnocontact', function () {
    $bcnt = Buyer::doesnthave('contact')->count();
    $scnt = Seller::doesnthave('contact')->count();
    $this->comment("There are $bcnt buyers with contact");
    $this->comment("There are $scnt sellers with contact");
});

Używamy relacji polimorficznej – pierwsza próba

Na początku napiszemy sobie komendę, która loguje jakieś informacje o wybranym sprzedawcy:

Artisan::command('buyerinfo {id}', function (int $id) {
    $buyer = Buyer::with('contact')->find($id);
    $this->comment("Name: {$buyer->name}");
});

Teraz komenda, pozwalająca nam zapisać do istniejącego sprzedawcy jego dane kontaktowe:

Artisan::command('buyeraddcontact {id}', function (int $id) {
    $buyer = Buyer::find($id);
    $city = $this->ask('Enter city: ');
    $street = $this->ask('Enter street: ');
    $this->comment("Name: {$buyer->name}");
    $this->comment("City: $city");
    $this->comment("Street: $street");
    if ($this->confirm('Do you wish to continue?', true)) {
        $buyer->contact()->save(
            Contact::make(['city' => $city, 'street' => $street])
        );
    }
});

Korzystamy na naszym modelu-dziecku ze statycznej metody make, która tworzy ten model w pamięci (nie zapisuje, nie ma też wypełnionych pól 'contactable_type’ oraz 'contactable_id’.

Przekazujemy utworzony przez make, niekompletny i z niczym niepołączony kontakt do $buyer->contact()->save(), która to metoda nadaje odpowiednie 'contactable_type’ (nasz typ, czyli buyer) oraz 'contactable_id’ (id naszego buyera) i zapisuje do bazy danych kontakt połączony z modelem Buyer oraz jego instancją o podanym ID.

Możemy teraz zapisać sobie do bazy danych kontakt dla jakiegoś istniejącego buyera a następnie zmodyfikować komendę logującą:

Artisan::command('buyerinfo {id}', function (int $id) {
    $buyer = Buyer::with('contact')->find($id);
    $this->comment("Name: {$buyer->name}");
    $buyer->contact && $this->comment("City: {$buyer->contact->city}");
    $buyer->contact && $this->comment("Street: {$buyer->contact->street}");   
});

Teraz komenda logująca wypisuje imię sprzedającego oraz – jeżeli ten dane kontaktowe posiada – jego dane kontaktowe. Możemy dodać dane kontaktowe i sprawdzić, że zostały przyznane oraz porównać z jakimś sprzedawcą, któremu takich danych nie przypisaliśmy.

Używamy relacji polimorficznej na innym modelu – sprzedający i kontakt

Cała ta nasza zabawa byłaby niewiele warta, gdyby można było kontakt dopisywać do tylko jednego modelu. Wtedy skorzystalibyśmy z relacji niepolimorficznej 1 do 1 i tworzyli modele BuyerContact, SellerContact i tak dalej.

My jednak mamy relację polimorficzną. Na początek napiszmy funkcje logującą pojedynczego sellera:

Artisan::command('sellerinfo {id}', function (int $id) {
    $seller = Seller::with('contact')->find($id);
    $this->comment("Name: {$seller->name}");
    $seller->contact && $this->comment("City: {$seller->contact->city}");
    $seller->contact && $this->comment("Street: {$seller->contact->street}");
    
});

Teraz analogicznie tworzymy funkcję, która połączy sellera z kontaktem:

Artisan::command('selleraddcontact {id}', function (int $id) {
    $seller = Seller::find($id);
    $city = $this->ask('Enter city: ');
    $street = $this->ask('Enter street: ');
    $this->comment("Name: {$seller->name}");
    $this->comment("City: $city");
    $this->comment("Street: $street");
    if ($this->confirm('Do you wish to continue?', true)) {
        $seller->contact()->save(
            Contact::make(['city' => $city, 'street' => $street])
        );
    }
});

Contact::make tak samo tworzy niezapisany model kontakt z pewnymi polami wypełnionymi, ale pustym 'contactable_type’ oraz 'contactable_id’, ale teraz przekazujemy go do $seller->contact->save().

Tym razem 'contactable_type’ wskazuje na model Seller zaś 'contactable_id’ – na id naszego sprzedającego.

Mamy zatem 3 modele:

  • Sprzedający w relacji 1 do 1 rodzic-dziecko w stosunku do Kontaktu
  • Kupujący w relacji 1 do 1 rodzic-dziecko w stosunku do Kontaktu
  • Kontakt w relacji 1 do 1 dziecko-rodzic w stosunku do dowolnego modelu, jako że posiada pole 'contactable_type’, które może przyjąć dowolny model oraz pole 'contactable_id’ dla id instancji tego modelu – jedyna restrykcja jest taka, że jeden kontakt może być posiadany tylko przez 1 rodzica.

Relacja polimorficzna jeden do wielu – film, książka i recenzja

Tworzymy model książki razem z migracją, fabryką i seederem:

php artisan make:model Book -mfs

Ustalamy pola książki w jej migracji:

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('books', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
            $table->string('title');
            $table->integer('pages');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('books');
    }
};

Migrujemy zmiany i tworzymy model filmu razem z migracją, fabryką i seederem:

php artisan migrate;
php artisan make:model Movie -mfs;

Wypełniamy pola w migracji:

{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('movies', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
            $table->string('title');
            $table->integer('length');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('movies');
    }
};

Migrujemy zmiany i tworzymy model recenzji wraz z migracją:

php artisan migrate;
php artisan make:model Review -m;

Wypełniamy pola migracji recenzji oraz tworzymy interfejs 'reviewable’:

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('reviews', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
            $table->text('desc');
            $table->integer('grade');
            $table->morphs('reviewable');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('reviews');
    }
};

Migrujemy i przechodzimy do modelu Book, wypełnić fillable oraz stworzyć relację jeden do wielu polimorficzną z interfejsem 'reviewable’:


class Book extends Model
{
    use HasFactory;
    protected $fillable = ['title', 'pages'];

    public function reviews()
    {
        return $this->morphMany('App\Models\Review', 'reviewable');
    }
}

Książka – rodzic, posiada wiele dzieci recenzji, inne modele też mogą posiadać recenzje a zatem relacja polimorficzna, morphMany zamias hasMany, namespace i nazwa interfejsu zamiast nazwy klasy.

To samo w modelu Movie:

class Movie extends Model
{
    use HasFactory;
    protected $fillable = ['title', 'length'];

    public function reviews()
    {
        return $this->morphMany('App\Models\Review', 'reviewable');
    }
}

Teraz pora utworzyć połączenie ze strony dziecka, czyli recenzji. Recenzja może mieć jednego rodzica, ale dowolnego typu, zapewniając interfejs 'reviewable’:

class Review extends Model
{
    use HasFactory;
    protected $fillable = ['desc', 'grade'];

    public function reviewable()
    {
        return $this->morphTo();
    }
}

morphTo – odpowiednik belongsTo, ale recenzja nie wie, do jakiej klasy należy – może należeć do dowolnej, taka istota relacji polimorficznych. Jesteśmy gotowi iść dalej.

Tworzymy fabryki i seedery – książka, film

Fabryka książek:

class BookFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'title' => fake()->sentence(4),
            'pages' => random_int(50,500)
        ];
    }
}

Fabryka filmów:

class MovieFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'title' => fake()->sentence(4),
            'length' => random_int(90,160)
        ];
    }
}

Seeder książek:

use App\Models\Book;
class BookSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        Book::factory()
        ->count(10)
        ->create();
    }
}

Seeder filmów:

use App\Models\Movie;
class MovieSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        Movie::factory()
        ->count(10)
        ->create();
    }
}

Komendy:

php artisan db:seed --class=MovieSeeder;
php artisan db:seed --class=BookSeeder;

W przyszłości poznamy bardziej zaawansowane patenty na seeding, na razie nam to musi wystarczyć. Po wykonaniu komend widzimy fejkowe rekordy w bazie danych.

Używamy relacji – zapisanie jednej recenzji

Na początek napiszemy sobie funkcję logującą informacje o książce, bo to na niej przećwiczymy sobie dodawanie jednej recenzji:

Artisan::command('bookinfo {id}', function (int $id) {
    $book = Book::withCount('reviews')->find($id);
    $this->comment("Title: {$book->title}");
    $this->comment("Pages: {$book->pages}");
    $this->comment("Reviews: {$book->reviews_count}");
});

Teraz komenda zapisująca recenzję do książki metodą create na metodzie reviews:

Artisan::command('bookaddreview {id}', function (int $id) {
    $book = Book::find($id);
    $desc = $this->ask('Enter desc: ');
    $grade = (int)$this->ask('Enter grade: ');
    $this->comment("Title: {$book->title}");
    $this->comment("Desc: $desc");
    $this->comment("Grade: $grade");
    if ($this->confirm('Do you wish to continue?', true)) {
        $book->reviews()->create(
            ['desc' => $desc, 'grade' => $grade]
        );
    }
});

Jeżeli chcemy być bardziej dokładni, możemy użyć modelu Review i make jak w poprzednim przykładzie relacji 1 do 1:

Artisan::command('bookaddreview {id}', function (int $id) {
    $book = Book::find($id);
    $desc = $this->ask('Enter desc: ');
    $grade = (int)$this->ask('Enter grade: ');
    $this->comment("Title: {$book->title}");
    $this->comment("Desc: $desc");
    $this->comment("Grade: $grade");
    if ($this->confirm('Do you wish to continue?', true)) {
        $book->reviews()->save(
            Review::make(['desc' => $desc, 'grade' => $grade])
        );
    }
});

Możemy jeszcze usprawnić naszą komendę logującą, aby pokazywała średnią ilość ocen pod warunkiem, że książka ma jakieś recenzje:

Artisan::command('bookinfo {id}', function (int $id) {
    $book = Book::withCount('reviews')->find($id);
    $this->comment("Title: {$book->title}");
    $this->comment("Pages: {$book->pages}");
    $this->comment("Reviews: {$book->reviews_count}");
    if($book->reviews_count > 0){
        $avg = $book->reviews()->avg('grade');
        $this->comment("Average grade: {$avg}");
    }
});

Używanie relacji – zapisywanie wielu recenzji

Tworzymy naszą komendę logującą info o filmie, bo na nim sobie wypróbujemy dodawanie wielu recenzji na raz:

Artisan::command('movieinfo {id}', function (int $id) {
    $movie = Movie::withCount('reviews')->find($id);
    $this->comment("Title: {$movie->title}");
    $this->comment("Length: {$movie->length}");
    $this->comment("Reviews: {$movie->reviews_count}");
    if($movie->reviews_count > 0){
        $avg = $movie->reviews()->avg('grade');
        $this->comment("Average grade: {$avg}");
    }
});

Teraz komenda, która obrazuje użycie saveMany na filmie:

Artisan::command('moviereviews {id}', function (int $id) {
    $movie = Movie::find($id);
    $movie->reviews()->saveMany([
        Review::make(['desc' => "Great book", 'grade' => 5]),
        Review::make(['desc' => "Average book", 'grade' => 3]),
        Review::make(['desc' => "Worst book", 'grade' => 1]),
    ]);
});

To samo możemy zrobić z książką ponieważ relacja jest polimorficzna:

Artisan::command('bookreviews {id}', function (int $id) {
    $book = Book::find($id);
    $book->reviews()->saveMany([
        Review::make(['desc' => "Great book", 'grade' => 5]),
        Review::make(['desc' => "Average book", 'grade' => 3]),
        Review::make(['desc' => "Worst book", 'grade' => 1]),
    ]);
});

A zatem zarówno książka może mieć wiele recenzji jak i film może mieć wiele recenzji zaś recenzja jest polimorficzna – można ją podpinać zarówno do książek jak i filmów, ale jedna recenzja – jeden rodzic. Z drugiej strony relacji – jeden rodzic może mieć wiele dzieci (recenzji).

Wyliczanie średniej – piszemy kilka komend

Dla ćwiczenia piszemy sobie komendę wyliczającą średnią ilość stron w książce z naszej kolekcji:

Artisan::command('pagesavg', function () {
    $avg = Book::avg('pages');
    $this->comment("Average pages count is $avg");
});

Teraz to samo, ale dla filmów i ich długości:

Artisan::command('lenavg', function () {
    $avg = Movie::avg('length');
    $this->comment("Average movie length is $avg");
});

Teraz to samo, ale z użyciem fasady DB:

Artisan::command('lenavg2', function () {
    $avg = DB::table('movies')->avg('length');
    $this->comment("Average movie length is $avg");
});

Okej, spróbujmy sobie teraz napisać funkcję, która dla naszej polimorficznej recenzji wyświetla średnią ilość ocen:

Artisan::command('averagereview', function () {
    $avg = Review::avg('grade');
    $this->comment("Average grade is $avg");
});

Okej, a teraz pomyślmy, co by się stało, gdybyśmy chcieli napisać średnią ilość ocen dla wszystkich recenzji wszystkich książek, i tylko książek. Średnia ilość ocen pojedynczej książki wygląda tak:

Artisan::command('singlebookavg {id}', function (int $id) {
    $avg = Book::find($id)->reviews->avg('grade');
    $this->comment("Average review grade for book with id $id is $avg");
});

My jednak chcemy czegoś innego – średnia wszystkich ocen dla wszystkich książek, ale tylko książek. Warto się przyjrzeć jak wygląda tabela reviews w bazie danych.

Mamy tam poza 'desc’ i 'grade’ kolumnę 'reviewable_id’ (tam numer id książki/filmu) oraz kolumnę 'reviewable_type’ a tam widzę powtarzające się dwa różne typy:

  • App\Models\Book
  • App\Models\Movie

Zatem komenda, która loguje średnią ocen książek w naszej relacji polimorficznej wygląda tak:

Artisan::command('bookavg', function () {
    $avg = DB::table('reviews')
    ->where('reviewable_type', 'App\Models\Book')
    ->avg('grade');
    $this->comment("Average grade for book is $avg");
});

Napisanie komendy logującej średnią ocen filmów jest równie proste:

Artisan::command('movieavg', function () {
    $avg = DB::table('reviews')
    ->where('reviewable_type', 'App\Models\Movie')
    ->avg('grade');
    $this->comment("Average grade for Movie is $avg");
});

Powinniśmy już powoli zaczynać rozumieć, jak te relacje działają oraz coraz lepiej odnajdywać się w pracy z bazą danych przez Laravela. Oczywiście będziemy jeszcze nad tym pracować.

Relację polimorficzną wiele do wielu poznamy w następnym odcinku na nowym, mniej zaśmieconym projekcie. Wyjaśnimy sobie też kilka innych niezbędnych rzeczy.