Relacja polimorficzna wiele do wielu wymaga dogłębnego zrozumienia, dlatego doczekała się osobnego tutoriala. Poznajemy ją od strony migracji, modelu, używania oraz od strony SQLowej i bazodanowej. Po tym artykule powinniśmy wszystko doskonale rozumieć.

Polimorficzne wiele do wielu – zrozumienie konceptu relacji

Nasza relacja będzie polegać na tym, że mamy modele Post oraz Video oraz model Tag. Jeden post może mieć wiele tagów, jedno video może mieć wiele tagów, jeden tag może być przypisany do wielu postów i wielu video.

Będziemy potrzebowali trzech modeli (idziemy w minimalizm):

  • Post, zawierający tytuł, ID, timestamps
  • Video, zawierający tytuł, ID, timestamps,
  • Tag, zawierający tagname, ID, timestamps

Będziemy też potrzebowali tabeli piwotalnej taggables, która zawiera:

  • tag_id – ID taga,
  • taggable_type – jaki typ podpinamy w danym rekordzie (np. Post, albo Video)
  • taggable_id – jakie ID podpinamy w danym rekordzie (np. id posta albo video, które ma mieć tag o ID określonym w punkcie pierwszym)

To teraz jeszcze raz rzut oka na tabele:

posts
    id - integer
    post_title - string
 
videos
    id - integer
    video_title - string
 
tags
    id - integer
    tagname - string
 
taggables
    tag_id - integer
    taggable_id - integer
    taggable_type - string

Tak to w uproszczeniu będzie wyglądać. Zakładam, że już mniej więcej w Laravelu ogarnięci jesteśmy, ale podam jeszcze komendy, które będą nam potrzebne:

//utwórz projekt:
composer create-project laravel/laravel relationships
//potem ustaw bazę danych i migruj:
php artisan migrate
//komendy tworząca modele i migracje:
php artisan make:model Post -m;
php artisan make:model Video -m;
php artisan make:model Tag -m;
//komenda tworząca tabelę taggables:
php artisan make:migration create_taggables_table

Nasze migracje – jak wyglądają i dlaczego

Ja zazwyczaj robię 1 model z migracją, ustalam pola, migruję, tworzę następny model z migracją. Mam taką zasadę, że jedna komenda migrate nie powinna zawierać stosu różnych migracji – ale to tylko moje dziwactwo.

Tak czy inaczej, oto migracja tagów (funkcja up):

Schema::create('tags', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
            $table->string('tagname');
        });

A tak wyglądają post i video:

//posts:
Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
            $table->string('post_title');
        });
//videos:
Schema::create('videos', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
            $table->string('video_title');
      });

Teraz migracja tabeli piwotalnej taggables. Ona potrzebuje trzech pól (nie licząc ID każdego rekordu taggables):

  • tag_id
  • taggable_type
  • taggable_id

Migracja wygląda tak:

Schema::create('taggables', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
            $table->foreignId('tag_id')->constrained();
            $table->morphs('taggable');
        });

Funkcja morphs, którą już wcześniej wykorzystywaliśmy, tworzy dwa pola – taggable_type i taggable_id. W relacji polimorficznej wiele do wielu potrzebujemy jeszcze pola tag_id, które jest kluczem obcym do tabeli tags, do ich ID.

Warto już teraz zrozumieć, jak ta tabela taggables będzie wyglądać:

 id    tag_id 	taggable_type 	taggable_id 	
 1 	    1 	    App\Models\Post 	1
 4      1 	    App\Models\Post 	2
  • ID – id każdego rekordu taggables
  • tag_id – id taga, którego w danym rekordzie łączymy z jakimś modelem
  • taggable_type – typ modelu, z jakim łączymy
  • taggable_id – id instancji modelu, z jakim łączymy

Czyli powyższy zapis oznacza dwa rekordy, które łączą tag o id 1 z postem o id 1 oraz postem o id 2. Warto rozumieć, co chcemy stworzyć i jak te rekordy są przechowywane.

Łączymy modele ze sobą w relację wiele do wielu (polimorficzną)

Tak wygląda model naszego Posta oraz model Video:

//post:
(...)
use App\Models\Tag;
class Post extends Model
{
    use HasFactory;
    protected $fillable = ['post_title'];
    public function tags()
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}
//video:
(...)
use App\Models\Tag;
class Video extends Model
{
    use HasFactory;
    protected $fillable = ['video_title'];
    public function tags()
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}

Polimorficzny odpowiednik relacji wiele do wielu. Podajemy nazwę klasy oraz interfejs taggable. Drugi koniec tej relacji wygląda w ten sposób:

(...)
use App\Models\Post;
use App\Models\Video;
class Tag extends Model
{
    use HasFactory;
    protected $fillable = ['tagname'];
    public function posts()
    {
        return $this->morphedByMany(Post::class, 'taggable');
    }
 
    public function videos()
    {
        return $this->morphedByMany(Video::class, 'taggable');
    }
}

Mamy tutaj rozróżnienie do jakich postów dany tag został przypisany (może być do wielu) oraz do jakich video został przypisany (też może być do wielu). I oczywiście – post i video mogą mieć wiele tagów.

Dodawanie postów i tagów oraz logowanie – piszemy komendy

W console.php importujemy modele Tag oraz Post na samej górze pliku:

use App\Models\Post;
use App\Models\Tag;

Piszemy komendę, która pozwoli nam utworzyć posta w bazie danych:

Artisan::command('create_post', function () {
    $title = $this->ask('Enter post title: ');
    $this->comment("title: {$title}");
    if ($this->confirm('Do you wish to continue?', true)) {
        Post::create(['post_title' => $title]);
    }
});
//php artisan create_post

Możemy teraz wykonać komendę i dodać post. Sprawdzić możemy w bazie danych, ale równie dobrze można napisać własną komendę, przy okazji przećwiczymy nasze umiejętności:

Artisan::command('postinfo {id}', function (int $id) {
    $post = Post::withCount('tags')->find($id);
    $this->comment("Post ID: {$post->id}");
    $this->comment("Post title: {$post->post_title}");
    $this->comment("Tags: {$post->tags_count}");
});
//php artisan postinfo 1

Teraz dodawanie własnych tagów do bazy danych przy pomocy komendy:

Artisan::command('create_tag', function () {
    $tagname = $this->ask('Enter tagname: ');
    $this->comment("tagname: {$tagname}");
    if ($this->confirm('Do you wish to continue?', true)) {
        Tag::create(['tagname' => $tagname]);
    }
});
//php artisan create_tag

Analogicznie, tworzymy komendę do logowania informacji o tagu:

Artisan::command('taginfo {id}', function (int $id) {
    $tag = Tag::withCount('posts')->withCount('videos')->find($id);
    $this->comment("tag ID: {$tag->id}");
    $this->comment("tagnane: {$tag->tagname}");
    $this->comment("posts: {$tag->posts_count}");
    $this->comment("videos: {$tag->videos_count}");
});
//php artisan taginfo 1

Dodaj tag, usuń tag, usuń tagi – łączenie relacji

Używać będziemy funkcji:

  • syncWithoutDetaching(tag_id) – dodawanie taga o danym id
  • detach(tag_id) – usuwanie taga o danym id
  • detach() – usuwanie wszystkich tagów

Komenda dodająca do posta o przekazanym ID tag o przekazanym ID:

Artisan::command('addtagtopost {post_id}', function (int $post_id ) {
    $tag_id = (int)$this->ask('Enter tag ID: ');
    $post = Post::find($post_id);
    $tag = Tag::find($tag_id);
    $this->comment("post title: {$post->post_title}");
    $this->comment("tagname: {$tag->tagname}");
    if ($this->confirm('Do you wish to continue?', true)) {
        $post->tags()->syncWithoutDetaching($tag_id);
    }
});
//php artisan addtagtopost {POST_ID}

Dzięki syncWithoutDetaching nie tracimy poprzednich tagów ani też nie dodamy dwa razy tego samego tagu do tego samego posta.

Komenda usuwająca z posta o danym ID tag o konkretnym ID:

Artisan::command('detachfrompost {post_id}', function (int $post_id ) {
    $tag_id = (int)$this->ask('Enter tag ID: ');
    $post = Post::find($post_id);
    $tag = Tag::find($tag_id);
    $this->comment("post title: {$post->post_title}");
    $this->comment("tagname: {$tag->tagname}");
    if ($this->confirm('Do you wish to continue?', true)) {
        $post->tags()->detach($tag_id);
    }
});
//php artisan detachfrompost {POST_ID}

Tutaj żadnej filozofii – detach usuwa relację z tagiem o podanym ID.

Komenda usuwająca wszystkie tagi przypisane do konkretnego posta:

Artisan::command('detachallfrompost {post_id}', function (int $post_id ) {
    $post = Post::withCount('tags')->find($post_id);
    $this->comment("post title: {$post->post_title}");
    $this->comment("tags: {$post->tags_count}");
    if ($this->confirm('Do you wish to continue?', true)) {
        $post->tags()->detach();
    }
});
//php artisan detachallfrompost {POST_ID}

Mówiąc o usuwaniu mamy rzecz jasna na myśli usuwanie relacji. Tagi nadal istnieją, ale post o podanym ID nie ma już żadnych. Tym zajmuje się detach, który z pustymi argumentami usuwa po prostu wszystkie powiązania.

Możemy jeszcze dla treningu napisać komendę, która pozwoli dodać do posta tag, ale operując na nazwie tagu:

Artisan::command('addtagbyname {post_id}', function (int $post_id ) {
    $tag_name = $this->ask('Enter tagname: ');
    $post = Post::find($post_id);
    $tag = Tag::where('tagname', $tag_name)->first();
    $this->comment("post title: {$post->post_title}");
    $this->comment("tagname: {$tag->tagname}");
    if ($this->confirm('Do you wish to continue?', true)) {
        $post->tags()->syncWithoutDetaching($tag->id);
    }
});

Za pomocą where wyszukujemy tag, którego 'tagname’ jest takie, jakie podaliśmy w konsoli i do syncWithoutDetaching przekazujemy ID tego znalezionego taga.

Odrobina SQL oraz fasady DB w naszych relacjach

Tłumaczyłem już, jak wyglądają rekordy w tabeli piwotalnej taggables i co one oznaczają:

 id    tag_id 	taggable_type 	taggable_id 	
 1 	    1 	    App\Models\Post 	1
 4      1 	    App\Models\Post 	2

Możemy zaimportować fasadę DB i napisać z jej pomocą komendę, która sprawdzi, ile jest tagów połączonych z postami:

use Illuminate\Support\Facades\DB;
(...)
Artisan::command('posts_tags_count', function () {
   $post_tags = DB::table('taggables')
    ->where('taggable_type', 'App\Models\Post')
    ->count();
     $this->comment("number of tags for posts: {$post_tags}");
});

Tak samo możemy sprawdzić, ile jest tagów połączonych z typem Video:

Artisan::command('videos_tags_count', function () {
   $video_tags = DB::table('taggables')
   ->where('taggable_type', 'App\Models\Video')
   ->count();
   $this->comment("number of tags for videos: {$video_tags}");
});

Możemy również, wysilając nieco szare komórki, pomyśleć co trzeba zrobić, aby za pomocą DB otrzymać ilość tagów, jaki ma jeden Post o konkretnym ID:

Artisan::command('single_post_tags_count {post_id}', function (int $post_id) {
    $post_tags = DB::table('taggables')
    ->where('taggable_type', 'App\Models\Post')
    ->where('taggable_id', $post_id)
    ->count();
    $this->comment("number of tags for post with id {$post_id}: {$post_tags}");
    });

Teraz polecałbym przeskoczyć do SQL i zobaczyć naszą tabelę taggables. Wyświetlmy sobie typy, jakich używamy:

SELECT DISTINCT taggable_type
FROM taggables

Wszystkie rodzaje modeli, do których podpinamy tagi, zostaną wyświetlone. W moim przypadku to tylko App/Models/Post.

Teraz wyświetlmy sobie z jakich typów korzysta tag o ID 1:

SELECT DISTINCT taggable_type
FROM taggables
WHERE tag_id = 1;

W moim przypadku mam tag o ID 1 i korzysta on z jednego typu, jakim jest 'App/Models/Post’. Mógłby jednak być przypisany do wielu typów, np. 'App/Models/Video’, to relacja wiele do wielu polimorficzna zaś kolumna taggable_type odpowiada za przechowywanie typu.

Okej, teraz policzmy sobie ile tag o ID 1 ma przypisanych typów:

SELECT COUNT(DISTINCT taggable_type) AS liczba_roznych_taggable_type
FROM taggables
WHERE tag_id = 1;

W moim przypadku tag o ID jeden ma tylko 1 typ modelu, do jakiego go przypisano (App/Models/Post). Dobrze, teraz napiszmy sobie funkcję, która bierze ID taga i pokazuje do ilu typów został przypisany:

Artisan::command('count_types {tag_id}', function (int $tag_id) {
    $types = DB::table('taggables')
     ->where('tag_id', $tag_id)
     ->distinct()
     ->count('taggable_type');
   $this->comment("$types");
 });

Robimy to wszystko po to, aby zrozumieć w jaki sposób tabela piwotalna taggables przechowuje relacje.

Krótkie ćwiczenie: co oznacza tabela poniżej?

 id    tag_id 	taggable_type 	taggable_id 	
 1 	    1 	    App\Models\Post 	1
 4      1 	    App\Models\Post 	2

Oznacza jeden tag o ID 1 przypisany do dwóch różnych postów (post o id 1 oraz post o id 2).

Następne ćwiczenie: co oznacza tabela poniżej?

 id    tag_id 	taggable_type 	taggable_id 	
 1 	    1 	    App\Models\Post 	1
 4      1 	    App\Models\Video 	1

Oznacza jeden tag o ID 1 przypisany do jednego modelu Post o ID 1 oraz jednego modelu Video (też o id 1).

Okej. Wystarczy na dzisiaj.