Ćwiczmy relacje wiele do wielu w Laravelu. To dopiero początek tych zadań, ale od czegoś musimy zacząć. Do dzieła.

Ok, najpierw tworzymy tabelę piwotalną:

php artisan make:migration CreateReviewTag;

Ważna uwaga – nazwa tabeli piwotalnej to nazwy (liczba pojedyncza) naszych modeli w kolejności alfabetycznej.

Ok, nasza migracja:

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('review_tag', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
            $table->unsignedBigInteger('review_id');
            $table->unsignedBigInteger('tag_id');
            $table->foreign('review_id')->references('id')->on('reviews');
            $table->foreign('tag_id')->references('id')->on('tags');
        });
    }

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

Migrujemy i tworzymy relację w modelu:

class Review extends Model
{
    use HasFactory;
    
    protected $fillable = ["author", "content", "rating"];

    public function movie(){
        return $this->belongsTo(Movie::class);
    }

    public function tags(){
        return $this->belongsToMany(Tag::class);
    }
}

I to samo w tags, warto zwrócić uwagę, że nazwy metod też mają swoje znaczenie, na ich podstawie Laravel wnioskuje query, więc jak chcemy, aby działało bez dopisywania tych argumentów, które zazwyczaj są opcjonalne, to nie mieszajmy tam.

class Tag extends Model
{
    use HasFactory;
    use SoftDeletes;

    protected $fillable = ['tagname'];

    public function reviews(){
        return $this->belongsToMany(Review::class);
    }
}

I oczywiście pamiętamy o importach. Ok, policzymy recenzje z tagami:

Artisan::command('review-has-tags', function () {

    $cnt = Review::has('tags')->count();
    $this->comment("Reviews with tags: $cnt");

});

Teraz tagi z recenzjami:

Artisan::command('tag-has-reviews', function () {

    $cnt = Tag::has('reviews')->count();
    $this->comment("Tags with reviews: $cnt");
    
});

Teraz dodawanie tagów do recenzji:

Artisan::command('r-add-tags {rId} {tId}', function (int $rId, int $tId) {

    $review = Review::findOrFail($rId);

    $review->tags()->syncWithoutDetaching([$tId]);

    $this->comment("Tags added to review");
    
});

Usuwanie jednego:

Artisan::command('r-detach-one {rId} {tId}', function (int $rId, int $tId) {

    $review = Review::findOrFail($rId);
    
    $review->tags()->detach([$tId]);

    $this->comment("Tag detached from review");
    
});

Czyszczenie wszystkich tagów:

Artisan::command('r-detach-all {id}', function (int $id) {

    $review = Review::findOrFail($id);
    
    $review->tags()->detach();

    $this->comment("Tags detached from review");
    
});

Ja zakładam, że my to robimy i trenujemy i obserwujemy w bazie.

Teraz kilka słów o metodach:

  • pusty detach niszczy wszystkie asocjacje
  • detach z id niszczy konkretną asocjację
  • attach może dodać jedną lub wiele asocjacji
  • attach jest „głupi” i nie sprawdza, czy taka asocjacja już nie istnieje i potrafi utworzyć zduplikowane rekordy dla jednej asocjacji
  • sync jak coś przekazujemy, to rozłącza wszystkie asocjacje i dopiero dodaje, duplikacji to pozwala uniknąć
  • syncWithoutDetaching to jest to, co nas kręci, dodaj asocjację, nie duplikuj rekordów, nie usuwaj poprzednich asocjacji

Ok, teraz tags count:

Artisan::command('r-tags-count {id}', function (int $id) {

    $review = Review::withCount('tags')->findOrFail($id);

    $this->comment("Review Movie_ID: {$review->movie_id}");
    $this->comment("Author: {$review->author}");
    $this->comment("Tags: {$review->tags_count}");
    
});

Znamy już to. Podobnie będzie, jak zechcemy te tagi wyświetlić:

Artisan::command('r-tags {id}', function (int $id) {

    $review = Review::with('tags')->findOrFail($id);

    $this->comment("Review Movie_ID: {$review->movie_id}");
    $this->comment("Author: {$review->author}");

    foreach($review->tags as $tag){
        $this->comment($tag->tagname);
   }
    
});

Fajnie by było to z implode wyświetlić, ale tags to klasa Collection, nie array. Cóż, po pierwsze mamy coś takiego jak pluck:

$collection = collect([
    ['product_id' => 'prod-100', 'name' => 'Desk'],
    ['product_id' => 'prod-200', 'name' => 'Chair'],
]);
 
$plucked = $collection->pluck('name');
 
$plucked->all();
 
// ['Desk', 'Chair']

Po drugie toArray, czyli tak będzie wyglądać nasza komenda:

Artisan::command('r-tags-imp {id}', function (int $id) {

    $review = Review::with('tags')->findOrFail($id);

    $this->comment("Review Movie_ID: {$review->movie_id}");
    $this->comment("Author: {$review->author}");
    
    $tagnames = $review->tags->pluck('tagname');

    $tagnamesAsString = implode(", ", $tagnames->toArray());
    
    $this->comment("Tags: {$tagnamesAsString}");
    
});

Na tym poziomie mam nadzieję, że ogarniamy już takie coś jak czym jest klasa Collection.

PS. Może zauważyliśmy, że nasza tabela piwotalna ma timestamps, ale w ogóle z nich nie korzystamy. Pora to zmienić:

class Tag extends Model
{
    use HasFactory;
    use SoftDeletes;

    protected $fillable = ['tagname'];

    public function reviews(){
        return $this->belongsToMany(Review::class)->withTimestamps();
    }
}

To samo robimy w reviews. Teraz nasze asocjacje mają created_at i updated_at. A jak się do nich dobrać?

Przykład z dokumentacji Laravela:

use App\Models\User;
 
$user = User::find(1);
 
foreach ($user->roles as $role) {
    echo $role->pivot->created_at;
}

W tabelach piwotalnych możemy trzymać co chcemy. Mogą tam być dodatkowe pola, np. priorytet, czy cokolwiek przyjdzie nam do głowy.