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.
- entre le catégories et les articles : une catégorie peut avoir plusieurs articles et réciproquement un article peut être rangé dans plusieurs catégories,
- entre les articles et les tags : un article peut avoir plusieurs tags et réciproquement un tag peut être référé par plusieurs articles.
Les catégories et les articles
Présentation
Voyons le premier cas entre les catégories et les articles. Vous avez sans doute remarqué que dans le menu de la page d'accueil on a un item catégories : Le fait de choisir une catégorie sélectionne uniquement les articles qui correspondent à cette catégorie. D'autre part dans l’affichage d'un article on mentionne la ou les catégories :Les données
Au niveau des tables on a ce schéma : On a déjà vu la table posts lors du précédent chapitre. La table categories est bien plus légère puisqu'elle ne comporte que deux colonnes :- title
- slug
- category_id pour mémoriser la clé de la table categories,
- post_id pour mémoriser la clé de la table posts.
Par convention le nom de la table pivot est composé des deux noms des tables au singulier (avec les règles anglaises) pris dans l’ordre alphabétique.
Les migrations
On a déjà vu dans le chapitre précédent la migration de la table posts. Celle de la table categories ne présente aucune particularité. Il est par contre intéressant d'aller voir la migration des clé étrangères dans le fichier database/migrations/ 2017_03_18_145916_create_foreign_keys.php :Schema::table('category_post', function(Blueprint $table) {
$table->foreign('category_id')->references('id')->on('categories')
->onDelete('cascade')
->onUpdate('cascade');
});
Schema::table('category_post', function(Blueprint $table) {
$table->foreign('post_id')->references('id')->on('posts')
->onDelete('cascade')
->onUpdate('cascade');
});
On retrouve le principe de ce qu'on a déjà vu dans le précédent chapitre. Par exemple dans la table on déclare une clé étrangère (foreign) nommée category_id qui référence (references) la colonne id dans la table (on) categories. En cas de suppression (onDelete) ou de modification (onUpdate) on a une cascade.
C'est la même chose pour l'autre clé avec une cascade. Je vous ai dit lors du précédent chapitre qu'on évitait en général les cascades. Mais on peut aussi les utiliser intelligemment. Le tout est de bien savoir ce qu'on fait.
La cascade ici est entre les deux tables encadrantes et la table pivot, autrement dit on veut que si une catégorie disparaît (ou un article) toutes les lignes de la table pivot en correspondance disparaissent aussi, ce qui est logique. Par contre en aucun cas on a le risque de voir un article (ou une catégorie) disparaître.
Les modèles
Le modèle Category se trouve ici : Il comporte en particulier ce code :public function posts()
{
return $this->belongsToMany(Post::class);
}
On déclare ici avec la méthode posts (au pluriel) qu’une catégorie appartient à plusieurs (belongsToMany) articles (Post). On aura ainsi une méthode pratique pour récupérer les articles d’une catégorie.
On a déjà vue le modèle Post lors du précédent article, on va se contenter de regarder ce code :
public function categories()
{
return $this->belongsToMany(Category::class);
}
C'est le même principe que pour les catégories puisque la relation est symétrique. On déclare avec la méthode categories (au pluriel) qu’un article appartient à plusieurs (belongsToMany) catégories (Category).
La relation n:n
Voici une schématisation de cette relation avec les deux méthodes symétriques :Les articles et les tags
Voyons maintenant le second cas de relation n:n de l'application, entre les articles et les tags.Présentation
Dans l'affichage d'un article on a les tags en relation : Ces tags sont cliquables et si on clique on se retrouve avec tous les articles qui sont en relation avec ce tag. On est prévenus par une petite barre d'information :Les données
Au niveau des tables on a ce schéma : La relation entre les deux tables (posts et tags) est assurée par la table pivot post_tag. Cette 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,
Les modèles
Dans le modèle Post on a ce code :public function tags()
{
return $this->belongsToMany(Tag::class);
}
C'est encore le même principe. On déclare avec la méthode tags (au pluriel) qu’un article appartient à plusieurs (belongsToMany) tags (Tag).
Dans le modèle Tag on a le code équivalent :
public function posts()
{
return $this->belongsToMany(Post::class);
}
On déclare avec la méthode posts (au pluriel) qu’un tag appartient à plusieurs (belongsToMany) articles (Post)
La relation n:n
Voici une schématisation de cette relation avec les deux méthodes symétriques :Jouer avec les relations
On va voir maintenant ce qu’on peut faire avec cette relation… Comme dans le chapitre précédent on va entrer directement du code dans le contrôleur Front/PostController, plus exactement dans la méthode index :public function index()
{
$posts = $this->postRepository->getActiveOrderByDate($this->nbrPages);
return view('front.index', compact('posts'));
}
Vous allez commenter les deux lignes de code et ça va constituer notre terrain de jeu. C’est la méthode qui est appelée avec la route de base de l’application.
Toutes les méthodes qu'on a vues pour la relation 1:n fonctionnent avec la relation n:n, ce qui est logique. Le fait qu'il y ait une table pivot ne change rien au fait que la relation, vue de l'une des deux tables, ressemble à s'y méprendre à une relation 1:n. Si je choisis une catégorie par exemple je sais qu'elle peut avoir plusieurs articles liés. On peut donc écrire ce code :
$categories = \App\Models\Category::all();
foreach($categories as $category) {
echo '<strong>' . $category->title . '</strong><br>';
foreach($category->posts as $post) {
echo $post->title . '<br>';
}
}
Et on obtient ça :
Category 1 Post 4 Post 7 Post 8 Post 9 Category 2 Post 3 Post 4 Post 5 Post 6 Post 7 Post 10 Category 3 Post 1 Post 2 Post 5 Post 6 Post 8 Post 10Et on peut écrire exactement l'inverse en partant de la table des articles (je limite le nombre d'enregistrement pour ne pas surcharger l'affichage) :
$posts = \App\Models\Post::take(4)->get();
foreach($posts as $post) {
echo '<strong>' . $post->title . '</strong><br>';
foreach($post->categories as $category) {
echo $category->title . '<br>';
}
}
Avec ce résultat :
Post 1 Category 3 Post 2 Category 3 Post 3 Category 2 Post 4 Category 1 Category 2On peut aussi faire de l'eager loading, placer des contraintes, etc... tout ce qu'on a vu lors du précédent chapitre.
Attacher et détacher
Par contre la nouveauté tient à la gestion de la table intermédiaire. Par exemple ci-dessus je vois que l'article Post 1 appartient seulement à Category 3. Si je veux lui ajouter Category 1 comment faire ? Dans ce cas on parle d'attachement, on va attacher la catégorie à l'article :$post = \App\Models\Post::whereTitle('Post 1')->first();
$category_id = \App\Models\Category::whereTitle('Category 1')->first()->id;
$post->categories()->attach($category_id);
On passe à la méthode attach l'identifiant (on peut en mettre plusieurs dans un tableau) de l'enregistrement en relation et Eloquent se charge de renseigner la table pivot :
On voit qu'on a ajouté la catégorie d'identifiant 1 pour l'article d'identifiant 1. Ce qui est bien ce qu'on voulait.
De la même manière la méthode detach permet de faire l'inverse :
$post = \App\Models\Post::whereTitle('Post 1')->first();
$category_id = \App\Models\Category::whereTitle('Category 1')->first()->id;
$post->categories()->detach($category_id);
Et on voit dans la table que la ligne a disparu :
Synchroniser
Dans les situations "réelles" on se retrouve souvent avec quelques identifiants à attacher tout en supprimant ceux qui y sont déjà. On pourrait le faire en deux étapes : d'abord tout détacher, puis attacher les nouveaux. Mais on peut aussi synchroniser avec la méthode sync :$post = \App\Models\Post::whereTitle('Post 1')->first();
$post->categories()->sync([2, 3]);
Avec ce résultat dans la table :
La catégorie 1 a disparu et on se retrouve avec la 2 et la 3.
Mais des fois on veut synchroniser sans détacher. Dans ce cas au lieu d'utiliser sync on utilise syncWithoutDetaching.
Échanger (toggle)
Prenons un dernier cas : je veux que si la catégorie est déjà présente elle disparaisse mais que si elle ne l'est pas elle le soit. Pas clair ? Voici un exemple avec la méthode toggle :$post = \App\Models\Post::whereTitle('Post 1')->first();
$post->categories()->toggle([1, 3]);
Avec ce résultat :
La catégorie 3 était présente, donc on l'a retirée. La catégorie 1 n'était pas présente, alors on l'a ajoutée.
L’application d’exemple
Enregistrement d'un article
Présentation
Si vous vous connectez en tant qu'administrateur vous avez accès dans l'administration à la liste des articles : Pour chaque article on a un bouton jaune qui permet d'accéder au formulaire de modification : Il y a un cadre pour les catégories (avec une liste à choix multiple) et un autre pour les tags (avec les tags séparés par des virgules).Route et contrôleur
Voici la route pour la soumission de ce formulaires : On a ces informations :- le verbe PUT/PATCH,
- l'url admin/posts/{post},
- le nom posts.update,
- le contrôleur App\Http\Back\PostController,
- la méthode update du contrôleur.
public function update(PostRequest $request, Post $post)
{
$this->authorize('manage', $post);
$this->repository->update($post, $request);
return back()->with('post-ok', __('The post has been successfully updated'));
}
Je vous ai déjà parlé de l'injection de dépendance, on a ici deux cas :
- la requête de formulaire pour la validation PostRequest,
- le modèle Post.
La validation
La validation s'effectue dans la requête de formulaire PostRequest dont voici les règles :public function rules()
{
$regex = '/^[A-Za-z0-9-éèàù]{1,50}?(,[A-Za-z0-9-éèàù]{1,50})*$/';
$id = $this->post ? ',' . $this->post->id : '';
return $rules = [
'title' => 'bail|required|max:255',
'body' => 'bail|required|max:65000',
'slug' => 'bail|required|max:255|unique:posts,slug' . $id,
'excerpt' => 'bail|required|max:65000',
'meta_description' => 'bail|required|max:65000',
'meta_keywords' => 'bail|required|regex:' . $regex,
'seo_title' => 'bail|required|max:255',
'image' => 'bail|required|max:255',
'categories' => 'required',
'tags' => 'nullable|regex:' . $regex,
];
}
Voyons de plus près la règle pour les tags :
'tags' => 'nullable|regex:' . $regex,
On applique une expression régulière :
$regex = '/^[A-Za-z0-9-éèàù]{1,50}?(,[A-Za-z0-9-éèàù]{1,50})*$/';
En effet on veut une succession de mots séparés par des virgules. On a pas ça dans la batterie des règles de Laravel, il faut donc s'adapter. La même expression est d'ailleurs utilisée pour les mots clés META.
Si par exemple vous insérez un espace à la soumission vous aurez une erreur :
Rappel : pour avoir l'interface en français il faut mettre la locale à fr dans le fichier config/app.php.
Mais où se trouve ce texte spécifique à cette règle ? regardez dans le fichier resources/lang/fr/validation.php :'custom' => [
'tags' => [
'regex' => "Les marques, séparées par des virgules (sans espaces), doivent avoir au maximum 50 caractères.",
],
'meta_keywords' => [
'regex' => "Les mots clés, séparés par des virgules (sans espaces), doivent avoir au maximum 50 caractères.",
],
],
Tous les textes personnalisés doivent être placés dans custom.
Le repository
C'est dans le repository PostRepository qu'on va trouver la gestion des données :public function update($post, $request)
{
$request->merge(['active' => $request->has('active')]);
$post->update($request->all());
$this->saveCategoriesAndTags($post, $request);
}
On a :
- l'ajout (merge) dans les entrées de la case à cocher (active),
- la mise à jour de l'enregistrement (update) à partir des entrées du formulaire,
- la délégation a la fonction saveCategoriesAndTags pour la mise à jour des catégories et des tags.
protected function saveCategoriesAndTags($post, $request)
{
$post->categories()->sync($request->categories);
$tags_id = [];
if ($request->tags) {
$tags = explode(',', $request->tags);
foreach ($tags as $tag) {
$tag_ref = Tag::firstOrCreate(['tag' => $tag]);
$tags_id[] = $tag_ref->id;
}
}
$post->tags()->sync($tags_id);
}
On a la synchronisation (sync) des catégories.
Pour les tags c'est un peu plus délicat pour synchroniser :
- on vérifie si on a des tags : if ($request->tags) {
- on crée un tableau avec les tags saisis : $tags = explode(',', $request->tags)
- on boucle dans le tableau : foreach ($tags as $tag) {
- on crée au besoin le tag s'il n'existe pas (firstOrCreate) et on récupère son identifiant : $tag_ref = Tag::firstOrCreate(['tag' => $tag])
- on remplit un tableau avec les identifiants : $tags_id[] = $tag_ref->id
- on utilise à la fin ce tableau pour synchroniser les tags de l'article : $post->tags()->sync($tags_id)
La vue
Au niveau de la vue pour ce formulaire on la trouve ici : Avec ce code :@extends('back.posts.template')
@section('form-open')
<form method="post" action="{{ route('posts.update', [$post->id]) }}">
{{ method_field('PUT') }}
@endsection
On trouve peu de code parce que le formulaire est partagé avec la vue de création d'un article. On fait du coup appel au template back/posts/template. Dans ce template on trouve cette ligne :
@yield('form-open')
C'est ici qu'est inséré le code pour l'ouverture du formulaire qui est forcément différent pour une création ou une modification (pas la même url).
Remarquez l'utilisation de l'helper route pour créer la valeur de l'attribut action :
route('posts.update', [$post->id])
Ce qui génère un code de ce genre :
<form method="post" action="http://monsite.dev/admin/posts/8">
Mais si vous êtes observateur vous avez vu que la route attend un verbe PUT, or là on a un POST. Alors ?
La navigateurs ne savent tout simplement pas générer autre chose que du GET ou du POST alors il faut ruser. On utilise POST pour ne pas contrarier le navigateur mais on ajoute cette ligne :{{ method_field('PUT') }}
Ce qui a pour effet de créer un champ caché :
<input type="hidden" name="_method" value="PUT">
A l'arrivée Laravel repère ce champ est sait qu'on veut un PUT !
Je parlerai plus en détail des vues dans un chapitre ultérieur, en particulier des vues partielles et des composants.
La population (seeding)
Pour terminer un petit mot encore sur la population. J'avais précisé pour la relation 1:n qu'il faut respecter un ordre dans la population pour disposer de la clé étrangère. Pour la relation n:n c'est également vrai et on ne peut remplir la table pivot que lorsque les enregistrements des deux tables encadrantes ont été créés. On crée dont les articles et les tags, par exemple pour les tags on a :DB::table('tags')->insert([
['tag' => 'Tag1'],
['tag' => 'Tag2'],
['tag' => 'Tag3'],
['tag' => 'Tag4'],
['tag' => 'Tag5'],
['tag' => 'Tag6']
]);
Ici on n'utilise pas Eloquent mais directement le Query Builder en précisant le nom de la table concernée.
Les tags sont ensuite affectés de façon aléatoire aux articles (sauf pour un certain article pour une raison que nous verrons plus tard) :
foreach ($posts as $post) {
if ($post->id === 9) {
$numbers=[1,2,5,6];
$n = 4;
} else {
$numbers = range (1, $nbrTags);
shuffle ($numbers);
$n = rand (2, 4);
}
for($i = 0; $i < $n; ++$i) {
$post->tags()->attach($numbers[$i]);
}
}
On utilise la méthode attach qu'on a vue ci-dessus pour remplir la table pivot.
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 (attach, detach, sync, toggle...).
- On peut créer des règles de validation personnalisée avec une expression rationnelle.
Par bestmomo
Nombre de commentaires : 1