Laravel

Un framework qui rend heureux

Voir cette catégorie
Vers le bas
Cours Laravel 5.5 – les données – gérer un arbre
Mercredi 27 septembre 2017 13:49
On a souvent le cas d'une structure arborescente dans une base de données, par exemple pour un menu, des catégories et sous-catégories. Il existe deux grande façons d'aborder et résoudre ce problème :
  • on peut se contenter de prévoir une colonne pour mémoriser l'identifiant du parent. C'est simple mais contraint à une approche récursive pour retrouver les données.
  • on peut approcher ce problème sous forme d'ensembles imbriqués, c'est plus complexe mais bien plus performant (pour une explication en français c'est ici).
Dans un premier temps j'avais adopté la première approche puis je suis passé à la seconde parce que je n'étais pas satisfait du résultat en terme de performances. Plutôt que de coder tout ça je me suis appuyé sur un package déjà tout prêt. J'ai un peu hésité parce qu'il ne semble pas être maintenu : la dernière modification remonte à 3 ans. D'ailleurs les commandes artisans ne sont pas toutes fonctionnelles. Mais le jeu en vaut quand même la chandelle comme vous allez le voir dans cet article. J'ai utilisé ce package pour la gestion des commentaires sur les articles.

Aspect fonctionnel

Dans l'application d'exemple les utilisateurs inscrits peuvent laisser des commentaires : On peut commenter un commentaire et ainsi de suite. On a donc une organisation hiérarchique qui en plus correspond à la même entité. Le niveau d'imbrication est réglable dans l'administration :

Installer un package

L'installation d'un package dans Laravel est en général très simple et se limite le plus souvent à informer composer. Pour le cas de etrepat/baum il suffit de taper :
composer require baum/baum
Et le package se charge dans le dossier vendor. D'autre part le fichier composer.json est modifié :
"require": {
    ...
    "baum/baum": "^1.1",
    ...
},
Il faut ensuite informer Laravel que le package existe dans le fichier config/app.php :
'providers' => [

...

/*
 * Package Service Providers...
 */
Baum\Providers\BaumServiceProvider::class,

Cette étape est maintenant devenue inutile pour les packages prévus pour Laravel 5.5

Selon les packages il est ensuite parfois nécessaire de lancer d'autres opération pour publier une configuration spécifique, des vues... Ce n'est pas le cas avec notre package.

Les données

Les migrations

Pour fonctionner notre package a besoin que la table concernée comporte 4 colonnes particulières. Il faut donc les prévoir dans la migration :
class CreateCommentsTable extends Migration {

  public function up()
  {
      Schema::create('comments', function(Blueprint $table) {
           ...
           $table->integer('parent_id')->nullable()->index();
           $table->integer('lft')->nullable()->index();
           $table->integer('rgt')->nullable()->index();
           $table->integer('depth')->nullable();
           ...
      });
  }
On va donc les retrouver dans la table :

Les modèles

Le modèle pour les commentaires ne doit pas étendre la classe Illuminate\Database\Eloquent\Model mais la classe du package Baum/Node :
use Baum\Node;

class Comment extends Node

Les relations

Le tables des commentaires (comments) est en relation avec 3 autres tables :
  • posts : une relation 1:n (clé étrangère : post_id) parce que les articles peuvent avoir plusieurs commentaires,
  • users : une relation 1:n (clé étrangère : user_id) parce que les utilisateurs peuvent écrire plusieurs commentaires,
  • comments : une relation 1:n avec elle-même (clé étrangère : parent_id) parce que les commentaires peuvent avoir plusieurs commentaires enfants.
Peut-être cela vous semble-t-il un peu étrange cette relation réflexive des commentaires sur eux-même mais ça fonctionne. D'autre part on pourrait à partir de cette situation établir d'autres relations. La table comments peut très bien servir de pivot entre les tables users et posts et on pourrait établir des relation belongsToMany. Mais on n'en a pas besoin parce qu'il n'est pas vraiment utile de savoir pour un utilisateur tous les commentaires qu'il a écrits ou pour un article tous les utilisateurs qui ont commenté. Mais rien n'empêche d'établir plusieurs sortes de relations sur une même table, Eloquent permet cette souplesse. Une relation ne sert que lorsqu'elle est utilisée. Si vous regardez dans le modèle Post vous trouvez ce code :
/**
 * One to Many relation
 *
 * @return \Illuminate\Database\Eloquent\Relations\HasMany
 */
public function comments()
{
    return $this->hasMany(Comment::class);
}

/**
 * One to Many relation
 *
 * @return \Illuminate\Database\Eloquent\Relations\hasMany
 */
public function validComments()
{
    return $this->comments()->whereHas('user', function ($query) {
        $query->whereValid(true);
    });
}

/**
 * One to Many relation
 *
 * @return \Illuminate\Database\Eloquent\Relations\hasMany
 */
public function parentComments()
{
    return $this->validComments()->whereParentId(null);
}
Voyons ça de plus près :
  • comments : c'est la relation hasMany classique qu'on a déjà vue,
  • validComments : c'est la relation hasMany complétée par une contrainte : en effet lorsqu'un utilisateur commente la première fois il doit être modéré, il n'est pas encore valide, il y a une colonne booléenne valid dans la table users. Ici on vérifie donc que l'utilisateur est valide parce qu'on ne doit pas encore afficher ses commentaires,
  • parentComments : on veut les commentaires valides mais seulement ceux de premier niveau (qui ne sont donc pas un commentaire de commentaire), on teste juste que la clé étrangère à la valeur nulle, qu'il n'y a donc pas de parent.
Dans les deux autres modèles on a des relations classiques, belongsTo dans Comment et hasMany dans User.

Création d'un commentaire

Les routes

Si vous regardez dans le fichier des routes routes/web.php vous trouvez ces deux routes :
// Posts and comments
Route::prefix('posts')->namespace('Front')->group(function () {
    ...
    Route::name('posts.comments.store')->post('{post}/comments', 'CommentController@store');
    Route::name('posts.comments.comments.store')->post('{post}/comments/{comment}/comments', 'CommentController@store');
    ...
});
Vous remarquez que les routes sont dans un groupe pour mutualiser :
  • le préfixe post
  • l'espace de noms Front pour les contrôleurs
Les deux routes correspondent à ces urls :
  1. post/{post}/comments
  2. post/{post}/comments/{comment}/comments
Pourquoi deux routes pour créer les commentaires ? Parce qu'on a deux cas à traiter : 1 - le cas du commentaire parent créé avec le formulaire : Dans ce cas on a une soumission classique du formulaire et on transmet dans l'url l'identifiant de l'article avec le paramètre {post}. 2 - le cas de la réponse à un commentaire existant : Dans ce cas la soumission est faite en Ajax, pour éviter de recharger toute la page, et on transmet dans l'url l’identifiant du commentaire parent avec le paramètre {comment} en plus de celui de l'article.

Le contrôleur

La création d'un commentaire est traité dans la méthode store du contrôleur Front/CommentController :
/**
 * Store a newly created comment in storage.
 *
 * @param  \App\http\requests\CommentRequest $request
 * @param  \App\Models\Post  $post
 * @param  integer $comment_id
 * @return \Illuminate\Http\Response
 */
public function store(CommentRequest $request, Post $post, $comment_id = null)
{
    Comment::create ([
        'body' => $request->input('message' . $comment_id),
        'post_id' => $post->id,
        'user_id' => $request->user()->id,
        'parent_id' => $comment_id,
    ]);

    ...

    if (!$request->user()->valid) {
        $request->session()->flash('warning', __('Thanks for your comment. It will appear when an administrator has validated it.<br>Once you are validated your other comments immediately appear.'));
    }

    if($request->ajax()) {
        return response()->json();
    }

    return back();
}
Regardez la signature de la méthode :
public function store(CommentRequest $request, Post $post, $comment_id = null)
On a 3 paramètres :
  • $request : injection de la requête de formulaire pour la validation comme on l'a déjà vu,
  • $post : liaison implicite entre le paramètre {post} et le modèle Post, je rappelle qu'ainsi Laravel injecte directement une instance du modèle avec l'identifiant $post,
  • $comment_id : récupère le paramètre {comment} avec l'identifiant du commentaire parent ou rien du tout dans le cas d'un commentaire de premier niveau et dans ce cas on a la valeur par défaut null.
Dans la méthode on crée le commentaire avec la méthode store :
Comment::create ([
    'body' => $request->input('message' . $comment_id),
    'post_id' => $post->id,
    'user_id' => $request->user()->id,
    'parent_id' => $comment_id,
]);
Si l'utilisateur n'est pas valide (c'est son premier commentaire) on lui envoie un message :
if (!$request->user()->valid) {
    $request->session()->flash('warning', __('Thanks for your comment. It will appear when an administrator has validated it.<br>Once you are validated your other comments immediately appear.'));
}
Remarquez comment on peut récupérer une instance du modèle de l'utilisateur en cours avec $request->user(). D'autre par on envoie le texte du message en session valable juste pour une requête HTTP (flash). Le texte est de base en anglais, je parlerai dans un chapitre ultérieur de la localisation. Ensuite selon que la requête est en Ajax ou pas on envoie la réponse adaptée :
if($request->ajax()) {
    return response()->json();
}

return back();

Modification d'un commentaire

Dans l'application le rédacteur d'un commentaire peut ensuite le modifier. Il dispose d'un lien en forme d'icône représentant un stylo : Ça ouvre un formulaire avec le texte actuel : La soumission se fait en Ajax pour éviter de recharger la page. C'est la méthode update du contrôleur Front/CommentController qui gère cette modification :
public function update(CommentRequest $request, Comment $comment)
{
    ...

    $message = $request->input('message' . $comment->id);
    $comment->body = $message;
    $comment->save();

    return ['id' => $comment->id, 'message' => $message];
}
On a :
  • une requête de formulaire pour la validation (CommentRequest),
  • liaison implicite entre le paramètre {comment} et le modèle Comment.
La mise à jour se fait classiquement avec la méthode save. On retourne un tableau avec l'identifiant et le texte que Laravel transforme automatiquement en réponse JSON.

Suppression d'un commentaire

Dans l'application le rédacteur d'un commentaire peut ensuite le supprimer s'il a des remords. Il dispose d'un lien en forme d'icône représentant une poubelle : On a dans la page un formulaire classique caché :
<form action="http://monsite.fr/comments/16" method="POST" class="hide">
  <input type="hidden" name="_token" value="EK86XinyEke12ERSRx5Ddfu8hD6iNBBUIzUSe34y">
  <input type="hidden" name="_method" value="DELETE">
</form>
C'est la méthode destroy du contrôleur Front/CommentController qui gère cette suppression :
public function destroy(Comment $comment)
{
    ...

    $comment->delete();

    return back();
}
On a une liaison implicite entre le paramètre {comment} et le modèle Comment. La suppression se fait classiquement avec la méthode delete.

Affichage des commentaires

L'affichage des commentaires se fait lorsqu'on affiche un article. C'est géré par la méthode show du contrôleur Front/PostController :
public function show(Request $request, $slug)
{
    $user = $request->user();

    return view('front.post', array_merge($this->postRepository->getPostBySlug($slug), compact('user')));
}
On récupère l'utilisateur en cours avec $request->user() et les informations nécessaires dans le repository PostRepository (méthode getPostBySlug). On a déjà commencé à voir cette méthode dans le chapitre sur la relation 1:n :
public function getPostBySlug($slug)
{
    // Post for slug with user, tags and categories
    $post = $this->model->with([
        'user' => function ($q) {
            $q->select('id', 'name', 'email');
        },
        'tags' => function ($q) {
            $q->select('tags.id', 'tag');
        },
        'categories' => function ($q) {
            $q->select('title', 'slug');
        }
    ])
    ->with(['parentComments' => function ($q) {
        $q->with('user')
            ->latest()
            ->take(config('app.numberParentComments'));
    }])
    ->withCount('validComments')
    ->withCount('parentComments')
    ->whereSlug($slug)
    ->firstOrFail();

    // Previous post
    $post->previous = $this->getPreviousPost($post->id);

    // Next post
    $post->next = $this->getNextPost($post->id);

    return compact('post');
}
On va s'intéresser cette fois à cette partie du code :
->with(['parentComments' => function ($q) {
    $q->with('user')
        ->latest()
        ->take(config('app.numberParentComments'));
}])
->withCount('validComments')
->withCount('parentComments')
On charge (with) les commentaires de niveau un (parentComments) ainsi que les rédacteurs des commentaires (with('user')), à partir du plus récent (latest) et on en prend (take) que ce qui est indiqué dans la configuration (app.numberParentComments). D'autre part on récupère aussi le nombre de commentaires valides (->withCount('validComments')) et le nombre de commentaires de niveau un (->withCount('parentComments')). Dans la vue front/post.blade.php on a ce code :
@if ($post->valid_comments_count)
    ...
    <ol class="commentlist">
        @include('front/comments/comments', ['comments' => $post->parentComments])
    </ol>
    ...
@endif
Si on a des commentaires valides on inclut (@include) la vue front/comments/comments :
@foreach($comments as $comment)
    @include('front/comments/comments-base')
@endforeach
Dans cette vue pour chaque (@foreach) commentaire on inclut la vue front/comments/comments-base. Je ne vais pas analyser tout le code mais me focaliser sur une partie intéressante :
@unless ($comment->isLeaf())
    ...
    <ul class="children">
        @include('front/comments/comments', ['comments' => $comment->getImmediateDescendants()])
    </ul>
@endunless
A moins que (@unless) le commentaire n'ait plus d'enfant ($comment->isLeaf()) on inclut à nouveau la vue front/comments/comments en passant en paramètres tous les commentaires immédiatement descendants ($comment->getImmediateDescendants()). On itère donc dans tous les commentaires pour les afficher de façon hiérarchique.

La méthode getImmediateDescendants fait partie des nombreuses méthodes disponibles avec le package pour questionner les nœuds.

La méthode isLeaf fait partie des nombreuses méthodes disponibles avec le package pour accéder aux nœuds.

Afficher plus de commentaires

On a vue que dans la configuration (config/app.php) on limite le nombre de commentaires de premier niveau à afficher, par défaut la valeur est 2 :
'numberParentComments' => 2,
S'il y a encore des commentaires à afficher on présente un bouton : On trouve ce code dans la vue front/post.blade.php pour gérer l'apparition conditionnelle du bouton :
@if ($post->parent_comments_count > config('app.numberParentComments'))
    <p id="morebutton" class="text-center">
        <a id="nextcomments" href="{{ route('posts.comments', [$post->id, 1]) }}" class="button">@lang('More comments')</a>
    </p>
    ...
@endif
La requête passe en Ajax pour éviter de recharger la page. Elle est gérée par la méthode comments du contrôleur Front/CommentController :
public function comments(Post $post, $page)
{
    $comments = $this->commentRepository->getNextComments($post, $page);
    $count = $post->parentComments()->count();
    $level = 0;

    return [
        'html' => view('front/comments/comments', compact('post', 'comments', 'level'))->render(),
        'href' => $count <= config('app.numberParentComments') * ++$page ?
            'none'
            : route('posts.comments', [$post->id, $page]),
    ];
}
C'est le repository CommentRepository qui est chargé de récupérer les commentaires :
public function getNextComments(Post $post, $page)
{
    return $post->parentComments()
        ->with('user')
        ->latest()
        ->skip($page * config('app.numberParentComments'))
        ->take(config('app.numberParentComments'))
        ->get();
}
On prend :
  • les commentaires de niveau un (parentComments)
  • en chargeant (with) les rédacteurs (user),
  • en commençant par les plus récents (latest),
  • en sautant (skip) ceux qui sont déjà affichés,
  • en prenant (take) la quantité définie dans la configuration

La population (seeding)

Je ne vous ai pas encore parlé des factories... Les factories sont des classes principalement consacrées aux tests mais qui peuvent aussi servir en d’autres occasions comme ici pour créer des enregistrements. On les trouve ici : Par défaut on a juste le factory pour le modèle User :
$factory->define(App\User::class, function (Faker $faker) {
    static $password;

    return [
        'name' => $faker->name,
        'email' => $faker->unique()->safeEmail,
        'password' => $password ?: $password = bcrypt('secret'),
        'remember_token' => str_random(10),
    ];
});
Sans entrer dans les détails on utilise le composant Faker pour générer des données aléatoires pour les propriétés du modèle User. Par exemple avec ce code :
factory(User::class, 15)->create();
On crée directement 15 utilisateurs ! Voici le factory pour les commentaires (CommentFactory) :
$factory->define(App\Models\Comment::class, function (Faker $faker) {

    return [
        'body' => $faker->paragraph($nbSentences = 4, $variableNbSentences = true),
    ];
});
Ensuite les commentaires peuvent facilement être créés en ajoutant les informations manquantes dans le factory (ou en les surchargeant) :
$comment1 = factory(Comment::class)->create([
    'post_id' => 2,
    'user_id' => 3,
]);
On utilise une méthode du package baum (makeChildOf) pour hiérarchiser les commentaires :
factory(Comment::class)->create([
    'post_id' => 4,
    'user_id' => 5,
    //'parent_id' => $nbrComments,
])->makeChildOf($comment2);

En résumé

On a vu dans ce chapitre comment utiliser un package pour gérer une situation particulière, ici un arbre de données hiérarchiques en simplifiant ainsi le codage final.  


Par bestmomo

Nombre de commentaires : 7