Cours Laravel 5.3 – les données – la relation n:n (1/2)

Dans le précédent chapitre nous avons vu la relation de type 1:n, la plus simple et la plus répandue. Nous allons maintenant étudier la relation de type n:n, plus délicate à comprendre et à mettre en œuvre. Nous allons voir qu’Eloquent permet de simplifier la gestion de ce type de relation.

Je vais poursuivre l’exemple du blog personnel débuté au chapitre précédent avec la possibilité d’ajouter des mots-clés (tags) aux articles.

Les données

La relation n:n

Imaginez une relation entre deux tables A et B qui permet de dire :

  • je peux avoir une ligne de la table A en relation avec plusieurs lignes de la table B,
  • je peux avoir une ligne de la table B en relation avec plusieurs lignes de la table A.

Cette relation ne se résout pas comme nous l’avons vu au chapitre précédent avec une simple clé étrangère dans une des tables. En effet il nous faudrait des clés dans les deux tables et plusieurs clés, ce qui n’est pas possible à réaliser.

La solution consiste à créer une table intermédiaire (nommée table pivot) qui sert à mémoriser les clés étrangères.

Voici un schéma de ce que nous allons réaliser :

La table pivot   contient les clés des deux tables :

  • post_id pour mémoriser la clé de la table posts,
  • tag_id pour mémoriser la clé de la table tags.

De cette façon on peut avoir plusieurs enregistrements liés entre les deux tables, il suffit à chaque fois d’enregistrer les deux clés dans la table pivot. Évidemment au niveau du code ça demande un peu d’intendance parce qu’il y a une table supplémentaire à gérer.

Par convention le nom de la table pivot est composé des deux noms des tables au singulier pris dans l’ordre alphabétique.

Les migrations

Nous allons continuer à utiliser les tables users et posts que nous avons vues aux chapitres précédents. Nous allons créer une nouvelle table tags destinée à mémoriser les mots-clés. Commencez par supprimer toutes les tables de votre base de données, sinon vous risquez de tomber sur des conflits avec les enregistrements que nous allons créer.

Supprimez aussi la table migrations.

Normalement vous devez déjà disposer des migrations pour les tables users, password_resets et posts. Nous allons ajouter les deux tables : tags et post_tag.

Créez une nouvelle migration pour la table tags :

php artisan make:migration create_tags_table

Modifiez ainsi le code :

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateTagsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('tags', function(Blueprint $table) {
            $table->increments('id');
            $table->timestamps();
            $table->string('tag', 50)->unique();
            $table->string('tag_url', 60)->unique();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::drop('tags');
    }
}

On prévoit les champs :

  • id : clé unique incrémentée,
  • created_at et updated_at créées par timestamps,
  • tag : le mot clé unique limité à 50 caractères,
  • tag_url : la version du tag à inclure dans l’url (avec 60 comme limite pour couvrir les cas les plus défavorables).

Il nous faut deux champs pour le tag, en effet il va falloir qu’on le transmette dans l’url pour la recherche par tag, or l’utilisateur risque de rentrer des accents par exemple (ou pire des « / »), nous allons convertir ces caractères spéciaux en caractères adaptés aux urls.

Créez une nouvelle migration pour la table  post_tag :

php artisan make:migration create_post_tag_table

Complétez ainsi le code :

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreatePostTagTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('post_tag', function(Blueprint $table) {
            $table->increments('id');
            $table->integer('post_id')->unsigned();
            $table->integer('tag_id')->unsigned();
            $table->foreign('post_id')->references('id')->on('posts')
                        ->onDelete('restrict')
                        ->onUpdate('restrict');

            $table->foreign('tag_id')->references('id')->on('tags')
                        ->onDelete('restrict')
                        ->onUpdate('restrict');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('post_tag', function(Blueprint $table) {
            $table->dropForeign('post_tag_post_id_foreign');
            $table->dropForeign('post_tag_tag_id_foreign');
        });

        Schema::drop('post_tag');
    }
}

On prévoit les champs :

  • post_id : clé étrangère pour la table posts,
  • tag_id : clé étrangère pour la table tags.

J’ai encore prévu l’option restrict pour les cascades pour sécuriser les opérations sur la base.

Normalement vous devez avoir ces migrations :

Lancez les migrations :

Vous devez ainsi vous retrouver avec ces 6 tables dans votre base :

Pour ne pas recevoir d’erreur il faut que les migrations se fassent dans le bon ordre ! L’ordre des migrations est donné par leur date de création, il suffit donc de changer la date dans le nom des migrations pour en changer l’ordre.

Les modèles

On va avoir besoin de déclarer la relation n:n dans le modèle Post :

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    protected $fillable = [
        'titre','contenu','user_id'
    ];

    public function user() 
    {
        return $this->belongsTo(\App\User::class);
    }

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

La méthode tags permet de récupérer les tags qui sont en relation avec l’article. On utilise la méthode  belongsToMany d’Eloquent pour le faire.

On va aussi avoir besoin d’un modèle pour les tags :

php artisan make:model Tag

Complétez ainsi le code :

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Tag extends Model
{
    protected $fillable = [
        'tag','tag_url'
    ];

    public function posts()
    {
        return $this->belongsToMany('App\Post');
    } 
}

On a la méthode réciproque de la précédente : posts permet de récupérer les articles en relation avec le tag.

Voici une schématisation de cette relation avec les deux méthodes symétriques :

On se retrouve avec ces trois modèles :

On pourrait créer un dossier pour les ranger, mais comme il y en a peu on va les garder comme cela. Si on le faisait il faudrait adapter les espaces de noms en conséquence. Il faudrait surtout bien renseigner l’espace de nom dans le fichier config/auth.php pour le modèle User.

La population

Dans le précédent chapitre on a créé les fabriques (model factories) pour les modèles User et Post. On va ajouter (fichier database/factories/ModelFactory.php) celle pour le modèle Tag :

$factory->define(App\Tag::class, function (Faker\Generator $faker) {
    $tag = $faker->unique()->word();
    return [
        'tag' => $tag,
        'tag_url' => $tag,
    ];
});

On va se contenter d’un simple mot.

Faker nous offre la méthode unique pour nous assurer de l’unicité du mot choisi.

On va aussi modifier le fichier pour la population (database/seeds/DatabaseSeeder.php) :

<?php

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        factory(App\User::class, 5)
            ->create()
            ->each(function ($user) {
                $user->posts()->saveMany(factory(App\Post::class, rand(2, 5))->make());
            }
        );

        factory(App\Tag::class, 10)->create();

        $posts = App\Post::all();

        foreach ($posts as $post) {
            $numbers = range(1, 10);
            shuffle($numbers);
            $n = rand(2, 4);
            for($i = 0; $i < $n; ++$i) {
                $post->tags()->attach($numbers[$i]);
            }
        }
    }
}

On crée 10 tags et ensuite de façon aléatoire on en affecte aux articles.

Il ne reste plus qu’à lancer la population :

php artisan db:seed

Vérifiez dans votre base de données que vous avez des informations correctes.

La validation

Nous allons avoir un cas de validation un peu particulier. En effet comme je l’ai dit ci-dessus les tags vont être entrés dans un contrôle de texte séparés par des virgules. On a prévu dans la table tags qu’ils ne devraient pas dépasser 50 caractères.

On ne dispose pas dans l’arsenal des règles de validation de Laravel d’une telle possibilité, il va donc falloir la créer.

On a déjà créé une classe PostRequest dans le chapitre précédent, il faut ajouter la règle pour les tags :

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class PostRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'titre' => 'bail|required|max:255',
            'contenu' => 'required',
            'tags' => ['Regex:/^[A-Za-z0-9-éèàù]{1,50}?(,[A-Za-z0-9-éèàù]{1,50})*$/'],
        ];
    }
}

Comme le cas est particulier j’ai utilisé une expression rationnelle. Il ne reste plus qu’à traiter le message.

Si vous regardez dans le fichier resources/lang/en/validation.php vous trouvez ce code :

'custom' => [
    'attribute-name' => [
        'rule-name' => 'custom-message',
    ],
],

C’est ici qu’on peut ajouter des messages spécifiques. On va donc écrire :

'custom' => [
    'tags' => [
		'regex' => "tags, separated by commas (no spaces), should have a maximum of 50 characters.",
	],
],

On va faire la même chose dans le fichier du français (puisqu’on a localisé en français) :

'custom' => [
    'tags' => [
        'regex' => "Les mots-clefs, séparés par des virgules (sans espaces), doivent avoir au maximum 50 caractères alphanumériques.",
    ],
],

Le contrôleur et les routes

Le contrôleur

Maintenant que tout est en place au niveau des données et de la validation voyons un peu la gestion de tout ça. On a déjà un contrôleur PostController mais on doit le compléter pour le fonctionnement avec les tags :

<?php

namespace App\Http\Controllers;

use App\Repositories\PostRepository;
use App\Repositories\TagRepository;
use App\Http\Requests\PostRequest;
use App\Post;

class PostController extends Controller
{
    protected $postRepository;

    protected $nbrPerPage = 4;

    public function __construct(PostRepository $postRepository)
    {
        $this->middleware('auth')->except('index', 'indexTag');
        $this->middleware('admin')->only('destroy');

        $this->postRepository = $postRepository;
    }

    public function index()
    {
        $posts = $this->postRepository->getWithUserAndTagsPaginate($this->nbrPerPage);

        return view('posts.liste', compact('posts'));
    }

    public function create()
    {
        return view('posts.create');
    }

    public function store(PostRequest $request, TagRepository $tagRepository)
    {
        $inputs = array_merge($request->all(), ['user_id' => $request->user()->id]);

        $post = $this->postRepository->store($inputs);

        if(isset($inputs['tags'])) {
            $tagRepository->store($post, $inputs['tags']);
        }

        return redirect(route('post.index'));
    }

    public function destroy(Post $post)
    {
        $this->postRepository->destroy($post);

        return back();
    }

    public function indexTag($tag)
    {
        $posts = $this->postRepository->getWithUserAndTagsForTagPaginate($tag, $this->nbrPerPage);

        return view('posts.liste', compact('posts'))
            ->with('info', 'Résultats pour la recherche du mot-clé : ' . $tag);
    }
}

J’ai ajouté la méthode indexTag qui doit lancer la recherche des articles qui comportent ce tag et envoyer les informations dans la vue liste. J’ai aussi un peu remanié le code.

Les routes

Il faut ajouter la route pour aboutir sur cette nouvelle méthode :

Route::resource('post', 'PostController', ['except' => ['show', 'edit', 'update']]);
Route::get('post/tag/{tag}', 'PostController@indexTag');

Vous devez donc avoir toutes ces routes :

Les repositories

Le repository pour les articles

Voici le repository pour les articles (app/Repositories/PostRepository.php) modifié pour tenir compte des tags :

<?php

namespace App\Repositories;

use App\Post;

class PostRepository
{
    protected $post;

    public function __construct(Post $post)
    {
        $this->post = $post;
    }

    protected function queryWithUserAndTags()
    {
        return $this->post->with('user', 'tags')->latest();
    }

    public function getWithUserAndTagsPaginate($n)
    {
        return $this->queryWithUserAndTags()->paginate($n);
    }

    public function getWithUserAndTagsForTagPaginate($tag, $n)
    {
        return $this->queryWithUserAndTags()
            ->whereHas('tags', function($query) use ($tag) {
                $query->where('tags.tag_url', $tag);
            })->paginate($n);
    }

    public function store($inputs)
    {
        return $this->post->create($inputs);
    }

    public function destroy(Post $post)
    {
        $post->tags()->detach();
        $post->delete();
    }
}

Vous êtes peut-être surpris par la longueur de certains des noms de fonctions. C’est un choix syntaxique. Je préfère des noms explicites, quitte à les allonger.

Le repositoy pour les tags

Et voici le repository pour les tags (app/Repositories/TagRepository.php) :

<?php
namespace App\Repositories;

use App\Tag;
use Illuminate\Support\Str;

class TagRepository
{
    protected $tag;

    public function __construct(Tag $tag)
    {
        $this->tag = $tag;
    }

    public function store($post, $tags)
    {
        $tags = explode(',', $tags);

        foreach ($tags as $tag) {

            $tag = trim($tag);

            $tag_url = Str::slug($tag);

            $tag_ref = $this->tag->where('tag_url', $tag_url)->first();

            if(is_null($tag_ref)) 
            {
                $tag_ref = new $this->tag([
                    'tag' => $tag,
                    'tag_url' => $tag_url
                ]); 

                $post->tags()->save($tag_ref);

            } else {
            
                $post->tags()->attach($tag_ref->id);

            }
        }
    }
}

Dans le prochain chapitre nous verrons comment tout ça fonctionne ainsi que les vues.

En résumé

  • Une relation de type n:n nécessite la création d’une table pivot.
  • Eloquent gère élégamment les tables pivots avec des méthodes adaptées.‌
  • On peut créer des règles de validation personnalisée avec une expression rationnelle.

Laisser un commentaire