VII. 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.
VII-A. Les données▲
VII-A-1. 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.
VII-A-2. 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 d'avoir 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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
<?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 URL.
Créez une nouvelle migration pour la table post_tag :
php artisan make:migration create_post_tag_table
Complétez ainsi le code :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
<?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 six 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.
VII-B. Les modèles▲
On va avoir besoin de déclarer la relation n:n dans le modèle Post :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
<?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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
<?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 noms dans le fichier config/auth.php pour le modèle User.
VII-C. 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 :
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) :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
<?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 dix 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.
VII-D. 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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
<?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 :
2.
3.
4.
5.
'
custom
'
=>
[
'
attribute-name
'
=>
[
'
rule-name
'
=>
'
custom-message
'
,
],
],
C'est ici qu'on peut ajouter des messages spécifiques. On va donc écrire :
2.
3.
4.
5.
'
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) :
2.
3.
4.
5.
'
custom
'
=>
[
'
tags
'
=>
[
'
regex
'
=>
"
Les mots-clés, séparés par des virgules (sans espaces), doivent avoir au maximum 50 caractères alphanumériques.
"
,
],
],
VII-E. Le contrôleur et les routes▲
VII-E-1. 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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
<?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.
VII-E-2. 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 :
VII-F. Les repositories▲
VII-F-1. Le repository pour les articles▲
Voici le repository pour les articles (app/Repositories/PostRepository.php), modifié pour tenir compte des tags :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
<?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.
VII-F-2. Le repositoy pour les tags▲
Et voici le repository pour les tags (app/Repositories/TagRepository.php) :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
<?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.
VII-G. 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.