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.