Les relations avec Eloquent (2/2)

Dans le dernier article j’ai détaillé les possibilités relationnelles d’Eloquent. Maintenant il nous reste à voir comment gérer tout ça. Le voyage va être parfois un peu mouvementé alors accrochez-vous…

La base

La base de référence qui va nous servir est la même que nous avons vue précédemment :

img40

Le code

Installation

Comme le code est volumineux je ne vais pas le mettre complètement ici mais juste m’y référer. Vous pouvez le trouver sur Github avec tous les renseignements nécessaires pour l’installation.

Vous devriez alors avoir une application fonctionnelle et arriver sur cette page :

img55

Organisation du code

Voici l’architecture des dossiers dans app :

img56

Vous pouvez remarquer la présence des dossiers Models et Repositories qui ont été créés spécialement pour l’application et ne font pas partie de l’arborescence de base de Laravel 5.

Le dossier Models contient tous les modèles :

img57

Le dossier Repositories contient tous les repositories de l’application :

img58

L’extension du FormBuilder

Pour simplifier les vues avec l’utilisation de bootstrap j’ai créé une extension du FormBuilder située dans le dossier Services :

img59

Par exemple pour faire apparaître une zone de texte avec son étiquette version bootstrap :

public function boottext($name, $label, $errors, $input = null)
{
    return sprintf('
        <div class="row">
            <div class="form-group %s">
                %s
                <div class="col-md-10">
                    %s
                    <small class="help-block">%s</small>
                </div>
            </div>
        </div>',
        $errors->has($name) ? 'has-error' : '',
        parent::label($name, $label, ["class" => "col-md-2"]),
        parent::text($name, $input, ['class' => 'form-control']),
        $errors->first($name)
    );
}

Ce qui génère en une seule ligne ce genre de code :

<div class="row">
	<div class="form-group">
		<label for="nom" class="col-md-2">Nom :</label>
		<div class="col-md-10">
			<input class="form-control" name="nom" type="text" value="Pays 5" id="nom">
		</div>
	</div>
</div>

Les routes

J’ai traité tous les contrôleurs comme des ressources, du coup les routes sont très épurées :

Route::get('/', function() { 
    return view('starter'); 
});

Route::resource('cities', 'CityController');
Route::resource('countries', 'CountryController');
Route::resource('authors', 'AuthorController');
Route::resource('books', 'BookController');
Route::resource('editors', 'EditorController');
Route::resource('formats', 'FormatController');
Route::resource('themes', 'ThemeController');
Route::resource('categories', 'CategoryController');
Route::resource('periods', 'PeriodController');

Les contrôleurs

Comme le traitement dans le cas de ressources est similaire la classe abstraite Controller contient du code commun :

<?php namespace App\Http\Controllers;

use Illuminate\Foundation\Bus\DispatchesCommands;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Foundation\Validation\ValidatesRequests;

use App\Http\Requests\SharedRequest;

abstract class Controller extends BaseController {

    use DispatchesCommands, ValidatesRequests;

    /**
     * The UserRepository instance.
     *
     * @var App\Repositories\UserRepository
     */
    protected $repository;

    /**
     * The base view path.
     *
     * @var string
     */
    protected $base;

    /**
     * The store message.
     *
     * @var string
     */    
    protected $message_store;

    /**
     * The update message.
     *
     * @var string
     */    
    protected $message_update;

    /**
     * The delete message.
     *
     * @var string
     */    
    protected $message_delete;

    /**
     * Display a listing of the resource.
     *
     * @return Response
     */
    public function index()
    {

        $lines = $this->repository->getPaginate(10);
        $links = str_replace('/?', '?', $lines->render());

        return view($this->base.'.list', compact('lines', 'links'));
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  App\Http\Requests\SharedRequest $request
     * @param  int  $id
     * @return Response
     */
    public function update(SharedRequest $request, $id)
    {
        $this->repository->update($id, $request->all());

        return redirect(route($this->base.'.index'))->with('message_success', $this->message_update);
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  App\Http\Requests\SharedRequest $request
     * @return Response
     */
    public function store(SharedRequest $request)
    {
        $this->repository->store($request->all());

        return redirect(route($this->base.'.index'))->with('message_success', $this->message_store);
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param  int  $id
     * @return Response
     */
    public function destroy($id)
    {
        $this->repository->destroyById($id);

        return redirect(route($this->base.'.index'))->with('message_success', $this->message_delete);
    }

}

On se retrouve ainsi avec des contrôleurs plus légers.

Les repositories

Comme le traitement est similaire entre les modèles j’ai créé un repository abstrait de base :

<?php namespace App\Repositories;

abstract class ResourceRepository {

    protected $model;

    public function getPaginate($n)
    {
        return $this->model->paginate($n);
    }

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

    public function getById($id)
    {
        return $this->model->findOrFail($id);
    }

    public function update($id, Array $inputs)
    {
        return $this->getById($id)->fill($inputs)->save();
    }

    public function updateByModel($model, Array $inputs)
    {
        return $model->fill($inputs)->save();
    }

    public function destroyById($id)
    {
        return $this->getById($id)->delete();
    }

    public function getAllSelect()
    {
        return $this->model->lists('name', 'id');
    }

}

Les repositories sont ainsi allégés.

Les requêtes de formulaire

Si vous regardez dans le dossier des requêtes vous n’allez pas trouver grand chose :

img60

En effet, étant donné la similitude des tables j’ai utilisé une requête partagée :

<?php namespace App\Http\Requests;

use App\Http\Requests\Request;

class SharedRequest extends Request {

	/**
	 * Get the validation rules that apply to the request.
	 *
	 * @return array
	 */
	public function rules()
	{
		$table = $this->segment(1);

		$name = 'required|max:255|unique:' . $table;
			
		if ($this->isMethod('post'))
		{
			$rules = ['name' => $name];
		}
		
		$rules = ['name' => $name . ',name,' . $this->segment(2)];

		if($table == 'editors')
		{
			$rules['phone'] = 'required|regex:/^[0-9 ]+$/|min:8';
		}

		return $rules;
	}

}

Considérations générales

J’ai simplifié au maximum la situation pour me concentrer sur les procédures. Chaque table a un seul champ, je ne fais pas de tri pour l’affichage, etc…

Gestion des pays

On va commencer avec un cas simple, celui des pays. Au niveau des relations la table des pays est reliée à la table des villes par un lien 1:n :

img48

On a vu également qu’on peut atteindre les auteurs avec la méthode hasManyThrough.

Le modèle

Comme on a des espaces de noms il faut évidemment bien renseigner le code en conséquence pour qu’Eloquent s’y retrouve :

<?php namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Country extends Model {

    /**
     * The fillable attributes.
     *
     * @var string
     */
    public $fillable = ['name'];

    /**
     * Has Many relation
     *
     * @return Illuminate\Database\Eloquent\Relations\hasMany
     */
    public function cities()
    {
        return $this->hasMany('App\Models\City');
    }

    /**
     * Has Many Through relation
     *
     * @return Illuminate\Database\Eloquent\Relations\hasManyThrough
     */
    public function authors()
    {
        return $this->hasManyThrough('App\Models\Author', 'App\Models\City');
    }

}

Ce sera la même chose pour tous les modèles de l’application.

Le contrôleur

J’ai parlé plus haut de la classe abstraite pour les contrôleurs, ça se traduit pour celui des pays à peu de code :

<?php namespace App\Http\Controllers;

use App\Http\Controllers\Controller;
use App\Repositories\CountryRepository;

use Illuminate\Http\Request;

class CountryController extends Controller {

    public function __construct(CountryRepository $repository)
    {
        $this->repository = $repository;

        $this->base = 'countries';
        
        $this->message_store = 'The country has been added';
        $this->message_update = 'The country has been updated';
        $this->message_delete = 'The country has been deleted';
    }

    /**
     * Display the specified resource.
     *
     * @param  int  $id
     * @return Response
     */
    public function show($id)
    {
        $country = $this->repository->getByIdWithCitiesAndAuthors($id);

        return view($this->base.'.show', compact('country'));
    }

    /**
     * Show the form for editing the specified resource.
     *
     * @param  int  $id
     * @return Response
     */
    public function edit($id)
    {
        $country = $this->repository->getById($id);

        return view($this->base.'.edit', compact('country'));
    }

    /**
     * Show the form for creating a new resource.
     *
     * @return Response
     */
    public function create()
    {
        return view($this->base.'.create');
    }

}

La gestion est déléguée au repository CountryRepository qui est injectée dans le modèle. On a ainsi une bonne séparation des tâches.

La liste

Pour obtenir la liste des pays on passe par la méthode getPaginate de la classe abstraite ResourceRepository commune :

public function getPaginate($n)
{
    return $this->model->paginate($n);
}

Cette méthode est appelée par la méthode index du contrôleur de base :

public function index()
{
    $lines = $this->repository->getPaginate(10);
    $links = str_replace('/?', '?', $lines->render());

    return view($this->base.'.list', compact('lines', 'links'));
}

On voit ici qu’on appelle la vue list pour la table concernée, ici celle des pays. Le code des vues pour les listes est similaire pour toutes les tables, voici ce que ça donne pour les pays :

@extends('list')

@section('top')
    Countries list
    {!! link_to_route('countries.create', 'Add a country', null, ['class' => 'btn btn-info pull-right']) !!}
@stop

@section('title')
    Countries
@stop

@section('table')
    @foreach ($lines as $country)
        <tr>
            <td>{{ $country->id }}</td>
            <td class="text-primary"><strong>{{ $country->name }}</strong></td>
            <td>{!! link_to_route('countries.show', 'See', [$country->id], ['class' => 'btn btn-success btn-block']) !!}</td>
            <td>{!! link_to_route('countries.edit', 'Update', [$country->id], ['class' => 'btn btn-warning btn-block']) !!}</td>
            <td>
                {!! Form::open(['method' => 'DELETE', 'route' => ['countries.destroy', $country->id]]) !!}
                    {!! Form::submit('Delete', ['class' => 'btn btn-danger btn-block', 'onclick' => 'return confirm(\'Realy delete this country ?\')']) !!}
                {!! Form::close() !!}
            </td>
        </tr>
    @endforeach
@stop

Je ne rentre pas dans le détail de ce code, ni dans le template correspondant parce que ce n’est pas le sujet. Voici l’aspect de la page obtenue :

img61

La pagination est réglée à 10 enregistrements. Pour chaque ligne on peut voir le pays, le modifier ou le supprimer.

Voici les requêtes générées par Eloquent :

select count(*) as aggregate from `countries` (410μs)
select * from `countries` limit 10 offset 0 (360μs)

La fiche du pays

Pour voir un pays c’est la méthode show du contrôleur qui est appelée :

public function show($id)
{
    $country = $this->repository->getByIdWithCitiesAndAuthors($id);

    return view($this->base.'.show', compact('country'));
}

On fait appel à la méthode getByIdWithCitiesAndAuthors du repository de base :

public function getByIdWithCitiesAndAuthors($id)
{
	return $this->model->with('cities', 'authors')->find($id);
}

La méthode find permet de récupérer l’enregistrement à partir de son identifiant. Ensuite on prévoit les informations nécessaires pour la fiche du pays. On utilise un chargement lié pour minimiser le nombre de requêtes. Il est ensuite facile de récupérer les informations :

  • les champs du pays $country
  • les villes du pays sont récupérées avec $country->cities (méthode hasMany du modèle)
  • les auteurs du pays sont récupérés avec $country->authors (méthode hasManyThrough du modèle)

La vue récupère ces informations :

@extends ('sheet')

@section('title')
    <h1>Country sheet</h1>
@stop

@section('content')
    {!! HTML::bootpanel('Country name', $country->name) !!}
    @if($country->cities->count())
        {!! HTML::bootpanelmulti('Cities', $country->cities, 'name') !!}
    @endif
    @if($country->authors->count())
        {!! HTML::bootpanelmulti('Authors', $country->authors, 'name') !!}
    @endif
    
@stop

L’utilisation de l’extension du FormBuilder permet de rendre le code simple et lisible. Résultat obtenu :

img62

Au-dessous de chaque fiche j’ai prévu le rappel visuel des relations avec une image.

Voici les requêtes générées par Eloquent :

select * from `countries` where `countries`.`id` = '8' limit 1 (470μs)
select * from `cities` where `cities`.`country_id` in ('8') (350μs)
select `authors`.*, `cities`.`country_id` from `authors` inner join `cities` on `cities`.`id` = `authors`.`city_id` where `cities`.`country_id` in ('8') (410μs)

Modifier un pays

La modification d’un pays est simple parce que ça n’impacte pas la relation avec les villes. Au niveau du contrôleur :

public function edit($id)
{
	$country = $this->repository->getById($id);

	return view($this->base.'.edit', compact('country'));
}

Dans le repository de base on se contente de récupérer l’enregistrement :

public function getById($id)
{
    return $this->model->findOrFail($id);
}

Et dans la vue on affiche le seul champ disponible :

@extends ('form')

@section('form')
    {!! Form::model($country, ['route' => ['countries.update', $country->id], 'method' => 'put', 'class' => 'form-horizontal panel']) !!}
        {!! Form::bootlegend('Country update') !!}
        {!! Form::boottext('name', 'Name :', $errors) !!}
        {!! Form::bootbuttons(route('countries.index')) !!}
    {!! Form::close() !!}
@stop

Ce qui donne cet aspect :

img63

La validité est testé dans la requête de formulaire commune :

public function rules()
{
	$table = $this->segment(1);
	$name = 'required|max:255|unique:' . $table;
		
	...

	return $rules;
}

On sauvegarde avec la méthode update du controleur de base et on retourne :

public function update(SharedRequest $request, $id)
{
    $this->repository->update($id, $request->all());

    return redirect(route($this->base.'.index'))->with('message_success', $this->message_update);
}

La méthode update du repositoy de base assure la mise à jour dans la base :

public function update($id, Array $inputs)
{
    return $this->getById($id)->fill($inputs)->save();
}

La création est calquée sur ce modèle, je ne la présente donc pas.

Détruire un pays

N’ayez pas peur, rien de belliqueux là dedans. Étant donné qu’il y a dans la table des villes une clé étrangère avec l’identifiant du pays (en fait autant de clés que de pays en relation), on ne va pas supprimer un pays sans précaution, au risque d’avoir une clé étrangère qui ne se réfère à plus rien du tout. Un petit schéma pour bien voir ça :

img64

Si je ne veux pas que la valeur du champ country_id dans la table cities devienne orpheline j’ai le choix :

  • je peux demander à MySQL (ou autre) de faire une suppression en cascade. Dans ce cas si je supprime le pays, les villes associées sont aussi automatiquement supprimées dans la base (et ainsi de suite s’il y a une autre cascade). C’est la méthode radicale qu’en général on évite mais que j’ai quand même choisie pour alléger le code.
  • je peux demander à MySQL de m’interdire la suppression du pays. Du coup si je le fais je reçois une erreur de sa part. C’est l’attitude par défaut.

Du coup on a ce code dans le contrôleur de base :

public function destroy($id)
{
    $this->repository->destroyById($id);

    return redirect(route($this->base.'.index'))->with('message_success', $this->message_delete);
}

Et celui-ci dans le repository de base :

public function destroyById($id)
{
	return $this->getById($id)->delete();
}

Gestion des villes

Voyons à présent la gestion des villes. Il y a pas mal de similitude avec celle des pays, je vais donc m’attacher à montrer les particularités.

Au niveau des relations on a une double liaison :

img65

Un ville appartient à un pays (relation 1:n vue du côté n) et une ville possède plusieurs auteurs (relation 1:n vue du côté 1). Qu’est-ce que cela implique ?

Pour l’affichage de la liste il n’y a aucune différence avec les pays. Pour la suppression c’est pareil, on va faire attention parce que la table authors possède la clé étrangère city_id. Je ne reviens donc pas sur ces deux points, il suffit de vous reporter à la gestion des pays vue ci-dessus.

La fiche de la ville

Dans la fiche de la ville on va afficher le pays auquel elle appartient et tous les auteurs qui l’habitent. Ce qui donne ce code dans le contrôleur :

public function show($id)
{
	$city = $this->repository->getByIdWithCountryAndAuthors($id);

	return view($this->base.'.show', compact('city'));
}

On fait appel à la méthode getByIdWithCountryAndAuthors du repository :

public function getByIdWithCountryAndAuthors($id)
{
    return $this->model->with('country', 'authors')->find($id);
}

Et on utilise cette vue :

@extends ('sheet')

@section('title')
    <h1>City sheet</h1>
@stop

@section('content')
    {!! HTML::bootpanel('City name', $city->name) !!}
    {!! HTML::bootpanel('Country', $city->country->name) !!}
    @if($city->authors->count())
        {!! HTML::bootpanelmulti('Authors', $city->authors, 'name') !!}
    @endif
@stop

Et cet aspect :

img66

J’ai aussi prévu une illustration des relations sous la fiche pour la compréhension.

Voici les requêtes générées par Eloquent :

select * from `cities` where `cities`.`id` = '4' limit 1 (440μs)
select * from `countries` where `countries`.`id` in ('10') (290μs)
select * from `authors` where `authors`.`city_id` in ('4') (460μs)

Modifier une ville

La table cities comporte deux champs :

  • name : c’est le nom de la ville
  • country_id : c’est la clé étrangère qui se réfère au pays

Il faut donc permettre de modifier ces deux valeurs. Pour le nom c’est simple, un contrôle de texte fait l’affaire. Pour le pays c’est plus délicat, il faut proposer un choix parmi tous les pays, donc prévoir une liste de choix avec tous les noms des pays. Voici le code dans le contrôleur :

public function edit(CountryRepository $countryRepository, $id)
{
	$city = $this->repository->getByIdWithCountry($id);
	$countries = $countryRepository->getAllSelect();

	return view($this->base.'.edit', compact('city', 'countries'));
}

On fait appel à la méthode getByIdWithCountry du repository des villes :

public function getByIdWithCountry($id)
{
    return $this->model->with('country')->find($id);
}

On fait aussi appel à la méthode getAllSelect du repository de base pour le remplissage de la liste des pays :

public function getAllSelect()
{
	return $this->model->lists('name', 'id');
}

Ainsi la variable $city contiendra les informations de la table cities et la variables $countries contiendra tous les pays avec leur nom et leur id. Voici la vue associée :

@extends ('form')

@section('form')
    {!! Form::model($city, ['route' => ['cities.update', $city->id], 'method' => 'put', 'class' => 'form-horizontal panel']) !!}
        {!! Form::bootlegend('City update') !!}
        {!! Form::boottext('name', 'Name :', $errors) !!}
        {!! Form::bootselect('country_id', 'Country :', $countries, $city->country_id) !!}
        {!! Form::bootbuttons(route('cities.index')) !!}
    {!! Form::close() !!}
@stop

On obtient ce formulaire avec le nom de la ville et la liste de choix avec le bon pays sélectionné :

img67

Au retour on teste la validité et on enregistre tout simplement avec la méthode update du repository de base :

public function update($id, Array $inputs)
{
    return $this->getById($id)->fill($inputs)->save();
}

Créer une ville

Pour créer une ville c’est pratiquement la même chose pour le formulaire à part que le nom est vierge et la méthode du contrôleur plus légère :

public function create(CountryRepository $countryRepository)
{
	$countries = $countryRepository->getAllSelect();

	return view($this->base.'.create', compact('countries'));
}

Au retour il faut créer une nouvelle entité avec le repository de base :

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

Gestion des livres

Avec la gestion des livres on attaque un gros morceau. Voici les 3 relations :

img68

  • avec les éditeurs et les formats on a une relation polymorphique de type 1:n vue du côté n
  • avec les thèmes on a une relation de type 1:n vue du côté n
  • avec les auteurs on a une relation de type n:n

Nous avons déjà vu le deuxième cas avec les villes, le traitement sera donc identique. Par contre nous avons deux nouveaux cas à analyser attentivement.

Pour la relation avec les auteurs de type n:n on sait qu’il nous faut une table pivot, ici c’est author_book. On part du principe qu’un livre à au moins un auteur et une quantité maximale indéterminée d’auteurs.

Pour la relation polymorphique on sait qu’un livre appartient soit à un éditeur, soit à un format.

La fiche du livre

La fiche du livre doit comporter :

  • le titre du livre
  • le thème
  • le nom de l’éditeur ou du format
  • les noms des auteurs

Voici la méthode show du contrôleur

public function show($id)
{
    $book = $this->repository->getByIdWithAuthorsAndThemeAndBookable($id);

    return view($this->base.'.show', compact('book'));
}

On fait appel à la méthode getByIdWithAuthorsAndThemeAndBookable du repository. Un nom bien long mais très parlant pour une seule ligne de code :

public function getByIdWithAuthorsAndThemeAndBookable($id)
{
	return $this->model->with('authors', 'theme', 'bookable')->find($id);
}

Voici la vue correspondante :

@extends ('sheet')

@section('title')
    <h1>Book sheet</h1>
@stop

@section('content')
    {!! HTML::bootpanel('Book name', $book->name) !!}
    {!! HTML::bootpanel('Theme', $book->theme->name) !!}
    {!! HTML::bootpanel('Type : ' . explode('\\', $book->bookable_type)[2], $book->bookable->name) !!}
    {!! HTML::bootpanelmulti('Authors', $book->authors, 'name') !!}
@stop

Et le résultat :

img69

Vous remarquez comment Eloquent permet de faire ça avec simplicité.

Voici les requêtes générées :

select * from `books` where `books`.`id` = '7' limit 1 (570μs)
select `authors`.*, `author_book`.`book_id` as `pivot_book_id`, `author_book`.`author_id` as `pivot_author_id` from `authors` inner join `author_book` on `authors`.`id` = `author_book`.`author_id` where `author_book`.`book_id` in ('7') (440μs)
select * from `themes` where `themes`.`id` in ('15') (370μs)
select * from `formats` where `id` in ('18') (260μs)

Modifier un livre

Maintenant envisageons la modification d’un livre. Cette fois je vais partir à l’envers. On veut le formulaire suivant :

img70

Avec ces possibilités :

  • pour le titre un simple contrôle de texte
  • pour le thème une liste de choix avec tous les thèmes et au départ le bon thème sélectionné
  • pour les auteurs au départ autant de listes de choix que d’auteurs avec le bon auteur chaque fois sélectionné, un bouton de suppression pour chaque auteur et un bouton pour ajouter un auteur, on aura donc là un traitement dynamique du formulaire
  • pour les éditeurs deux boutons « radio » pour choisir entre les deux possibilités avec le bon bouton sélectionné au départ, et une liste de choix avec selon le bouton radio sélectionné les éditeurs ou les formats, avec évidemment le bon sélectionné au départ

Voici la méthode du contrôleur :

public function edit(
    AuthorRepository $authorRepository,
    ThemeRepository $themeRepository,
    EditorRepository $editorRepository,
    FormatRepository $formatRepository,
    $id)
{
    $book = $this->repository->getByIdWithAuthorsAndThemeAndBookable($id);
    $type_editor = $this->repository->getTypeEditor($book);

    $authors = $authorRepository->getAllSelect();
    $themes = $themeRepository->getAllSelect();
    $editors = $editorRepository->getAllSelect();
    $formats = $formatRepository->getAllSelect();

    return view($this->base.'.edit', compact('book', 'authors', 'themes', 'editors', 'formats', 'type_editor'));
}

On va regarder tout ça de près. On voit qu’on injecte 4 repositories en plus de celui implicite du contrôleur pour les livres.

Pour ce qui concerne le livre on a besoin de connaître les auteurs, le thème et la liaison polymorphique. Tout cela nous est donné par la méthode getByIdWithAuthorsAndThemeAndBookable du repository :

public function getByIdWithAuthorsAndThemeAndBookable($id)
{
	return $this->model->with('authors', 'theme', 'bookable')->find($id);
}

On va aussi récupérer le type de l’éditeur avec la méthode getTypeEditor du repository :

public function getTypeEditor($book)
{
	return $book->bookable_type == 'App\Models\Editor';
}

Pour remplir les listes des thèmes, des auteurs, des éditeurs et des formats on récupère ça avec la méthode getAllSelect du repository de base :

public function getAllSelect()
{
    return $this->model->lists('name', 'id');
}

La vue est évidemment un peu chargée pour traiter tout ça :

@extends('form')

@section('form')
    {!! Form::open(['route' => ['books.update', $book->id], 'method' => 'put', 'class' => 'form-horizontal panel']) !!}
        {!! Form::bootlegend('Update a book') !!}<br>
        {!! Form::boottext('name', 'Name :', $errors, $book->name) !!}<br>
        {!! Form::bootselect('theme_id', 'Theme :', $themes) !!}<br>
        @for ($i = 0; $i < count($book->authors); $i++)
            {!! Form::bootselectbutton('authors', 1, 'Author :', $authors, $book->authors[$i]->id) !!}    
        @endfor                  
        <div class="row">
            <button id="add" type="button" class="btn btn-primary pull-right">Add an author</button>
        </div>
        <br>
        <div class="row form-group">
            <label class="radio-inline">
                {!! Form::radio('bookable', 'editor', $type_editor) !!} Editor
            </label>
            <label class="radio-inline">
                {!! Form::radio('bookable', 'format', !$type_editor) !!} Format
            </label>
        </div>
        <div class="toggle {{ $type_editor? 'show' : 'hidden' }}">
            {!! Form::bootselect('editor_id', 'Editor :', $editors, $book->bookable->id) !!}
        </div>
        <div class="toggle {{ $type_editor? 'hidden' : 'show'}}">
            {!! Form::bootselect('format_id', 'Format :', $formats, $book->bookable->id) !!}
        </div>
        <br><hr>
        {!! Form::bootbuttons(url('books')) !!}
    {!! Form::close() !!}
@stop

@section('scripts')
    @include('books.script')
@stop

Comme la partie script est commune avec la création elle se situe dans une vue partielle :

<script>

	$(function(){
		// Delete an author line
		$(".btn-danger").click(function() {
			// Delete if at least 2
			if($('.line').length > 1) $(this).parents('.row .line').remove();	
		});
		// Add an author line
		$("#add").click(function() {
			// Check for last id
			var max = id = 0;
			$('.line').each(function(){
				id = parseInt(($(this).attr('id')).slice(-1));
				if(id > max) max = id;
			});
			// First line
			var clone = $('#lineauthors' + max).clone(true);
			// Change id
			clone.attr('id', 'lineauthors' + ++max);
			// Change label for
			clone.find('label').attr('for', 'author' + max);
			// Change select id
			clone.find('select').attr('id', 'author' + max);
			// Add line
			$('#lineauthors' + id).after(clone);			
		});
		// Change editor/format
		$('input[type="radio"]').change(function () {
			$('.toggle').toggleClass('show hidden');
		});

		// Submission 
		$(document).on('submit', 'form', function(e) {  
			e.preventDefault();
			$.ajax({
				method: $(this).attr('method'),
				url: $(this).attr('action'),
				data: $(this).serialize(),
				dataType: "json"
			})
			.done(function(data) {
				window.location.href = '{!! url('books') !!}';
			})
			.fail(function(data) {
				$.each(data.responseJSON, function (key, value) {
					if(key == 'name') {
						$('.help-block').eq(0).text(value);
						$('.form-group').eq(0).addClass('has-error');							
					}						
				});
			});
		});
	})

</script>

Je ne détaille pas tout ce code et il est sans doute améliorable, j’ai mis pas mal de commentaires. La soumission est effectuée en Ajax pour éviter de perdre les contrôles générés de façon dynamique.

Le traitement au retour est assez facile avec Eloquent. Dans le repository on trouve une méthode update :

public function update($id, Array $inputs)
{
    $this->save($this->getById($id), $inputs);
}

Comme il y a du code commun avec la création il est fait appel à une fonction privée save :

protected function save($book, Array $inputs)
{
	$book->theme_id = $inputs['theme_id'];
	$book->name = $inputs['name'];
	$type = $inputs['bookable'];
	$book->bookable_id = $type == 'editor'? $inputs['editor_id'] : $inputs['format_id'];
	$book->bookable_type = $type == 'editor'? 'App\Models\Editor' : 'App\Models\Format';
			
	$book->save();

	$book->authors()->sync(array_unique($inputs['authors']));
}

Notez la synchronisation élégante de la table pivot author_book avec cette simple ligne de code :

$book->authors()->sync(array_unique($inputs['authors']));

Création d’un livre

Pour créer un livre le formulaire est identique, il est juste non renseigné au départ. Je ne détaille donc pas cette partie du code.  Voici la méthode store du repository :

public function store(Array $inputs)
{
    $this->save(new $this->model, $inputs);
}

On utilise la même fonction que pour la mise à jour mais au lieu de transmettre le livre existant on en crée un, le reste est strictement identique.

Destruction d’un livre

Pour détruire un livre il n’y a pas de précaution particulière à prendre puisqu’on a choisi la cascade au niveau des clés étrangères.

Gestion des éditeurs

On va souffler un peu avec les éditeurs qui ne présentent pas de difficulté si ce n’est une relation de type 1:1 avec les contacts. et une relation polymorphique avec les livres :

img71

La fiche

Pour renseigner la fiche des éditeurs il faut aller récupérer le téléphone dans la table contacts. On va chercher aussi les livres de l’éditeur sélectionné pour les afficher. Voici le code du contrôleur :

public function show($id)
{
    $editor = $this->repository->getByIdWithContactAndBooks($id);

    return view($this->base.'.show', compact('editor'));
}

On fait appel à la méthode getByIdWithContactAndBooks du repository :

public function getByIdWithContactAndBooks($id)
{
	return $this->model->with('contact', 'books')->find($id);
}

La modification

De la même façon pour la modification d’un éditeur il faut aller chercher le téléphone. On a ce code dans le contrôleur :

public function edit($id)
{
    $editor = $this->repository->getByIdWithContact($id);

    return view($this->base.'.edit', compact('editor'));
}

On fait appel à la méthode getByIdWithContact du repository :

public function getByIdWithContact($id)
{
	return $this->model->with('contact')->find($id);
}

Au retour on doit mettre à jour les deux tables. On fait deux appels dans le contrôleur :

public function update(SharedRequest $request, $id)
{
    $this->repository->update($id, $request->only('name'));

    $this->repository->updateContact($id, $request->only('phone'));

    return redirect(route($this->base.'.index'))->with('message_success', $this->message_update);
}

Le premier update appelle la méthode de base :

public function update($id, Array $inputs)
{
	return $this->getById($id)->fill($inputs)->save();
}

Le second updateContact est destiné à mettre à jour la table contacts :

public function updateContact($id, $inputs)
{
	$editor = $this->getById($id);

	$editor->contact()->update($inputs);
}

La création

Pour créer un nouvel éditeur on a ce code dans le contrôleur :

public function store(SharedRequest $request)
{
    $editor = $this->repository->store($request->only('name'));

    $this->repository->saveContact($editor, $request->only('phone'));

    return redirect(route($this->base.'.index'))->with('message_success', $this->message_store);
}

On a encore deux appels. Le premier concerne la table editors :

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

Le second concerne la table contacts :

public function saveContact($editor, $inputs)
{
	$contact = new $this->contact($inputs);

	$editor->contact()->save($contact);
}

Eloquent se charge lui-même de renseigner le champ editor_id.

Gestion des thèmes

Voyons à présent la gestion des thèmes. Voyons la situation :

img72

La table des thèmes possède une triple relation (une de type 1:n et deux polymorphiques de type n:n) :

  • hasMany avec la table des livres
  • morphedByMany avec la table des catégories
  • morphedByMany avec la table des périodes

Comme on l’a vu dans le précédent article la polymorphie de type n:n remplace plusieurs relation de type n:n. Au lieu d’avoir autant de tables pivots que de relation on en a une seule qui les regroupe. L’identification se fait avec le nom du modèle en relation en plus des deux id. Un thème peut donc être en relation avec 0 ou plusieurs catégories et avec 0 ou plusieurs périodes.

La fiche du thème

Que nous faut-il comme information pour la fiche d’un thème ? Voici le code du contrôleur :

public function show($id)
{
	$theme = $this->repository->getByIdWithCategoriesAndPeriodsAndBooks($id);

	return view($this->base.'.show', compact('theme'));
}

On fait appel à la méthode getByIdWithCategoriesAndPeriodsAndBooks du repository :

public function getByIdWithCategoriesAndPeriodsAndBooks($id)
{
    return $this->model->with('categories', 'periods', 'books')->find($id);
}

On récupère : le thème sélectionné, les livres, les catégories et les périodes. Au niveau de la vue on a :

@extends ('sheet')

@section('title')
    <h1>Theme sheet</h1>
@stop

@section('content')
    {!! HTML::bootpanel('Theme name', $theme->name) !!}
    {!! HTML::bootpanelmulti('Categories', $theme->categories, 'name') !!}
    {!! HTML::bootpanelmulti('Periods', $theme->periods, 'name') !!}
    {!! HTML::bootpanelmulti('Books', $theme->books, 'name') !!}
@stop

Dans ce cas on a 3 panneaux multiples :

img73

Avec ces requêtes générées :

select * from `themes` where `themes`.`id` = '2' limit 1 (400μs)
select `categories`.*, `themables`.`theme_id` as `pivot_theme_id`, `themables`.`themable_id` as `pivot_themable_id` from `categories` inner join `themables` on `categories`.`id` = `themables`.`themable_id` where `themables`.`theme_id` in ('2') and `themables`.`themable_type` = 'App\Models\Category' (490μs)
select `periods`.*, `themables`.`theme_id` as `pivot_theme_id`, `themables`.`themable_id` as `pivot_themable_id` from `periods` inner join `themables` on `periods`.`id` = `themables`.`themable_id` where `themables`.`theme_id` in ('2') and `themables`.`themable_type` = 'App\Models\Period' (300μs)
select * from `books` where `books`.`theme_id` in ('2') (280μs)

La modification d’un thème

Voici le formulaire qu’on le veut :

img74

Trois parties :

  • pour le nom un simple contrôle de texte
  • pour les catégories des listes de choix avec traitement dynamique (de 0 à n listes)
  • pour les périodes des listes de choix avec traitement dynamique (de 0 à n listes)

Voici le code dans le contrôleur :

public function edit(
    CategoryRepository $categoryRepository,
    PeriodRepository $periodRepository,
    $id)
{
    $theme = $this->repository->getByIdWithCategoriesAndPeriods($id);

    $categories = $categoryRepository->getAllSelect();
    $periods = $periodRepository->getAllSelect();

    return view($this->base.'.edit', compact('theme', 'categories', 'periods'));
}

On a encore besoin de plusieurs repositories (pour les thèmes, les catégories et les périodes). On a déjà vu plusieurs fois la méthode getAllSelect et je n’en reparle donc plus. Voici la méthode pour les thèmes :

public function getByIdWithCategoriesAndPeriods($id)
{
	return $this->model->with('categories', 'periods')->find($id);
}

On prévoit d’envoyer les champs du thème ($theme) et les éléments de remplissage des listes. Pour le nombre de listes de choix à afficher ça dépend évidemment de la situation : envoi initial du formulaire ou nouveau remplissage après erreur de validation.

La vue est un peu chargée :

@extends ('form')

@section('form')
    {!! Form::model($theme, ['route' => ['themes.update', $theme->id], 'method' => 'put', 'class' => 'form-horizontal panel']) !!}
        {!! Form::bootlegend('Update theme') !!}
        {!! Form::boottext('name', 'Name :', $errors) !!}<br><hr>
        <div id="cats">
            @for ($i = 0; $i < count($theme->categories); $i++)
                {!! Form::bootselectbutton('categories', $i, 'Category :', $categories, $theme->categories[$i]->id) !!}                  
            @endfor
        </div>
        <div class="row">
            <button id="add_cat" type="button" class="btn btn-primary pull-right">Add a category</button>
        </div>
        <br><hr>
        <div id="pers">
            @for ($i = 0; $i < count($theme->periods); $i++)
                {!! Form::bootselectbutton('periods', $i, 'Period :', $periods, $theme->periods[$i]->id) !!}                      
            @endfor
        </div>
        <div class="row">
            <button id="add_per" type="button" class="btn btn-primary pull-right">Add a period</button>
        </div>
        <br><hr>
        {!! Form::bootbuttons(url('themes')) !!}
    {!! Form::close() !!}
    @stop

@section('scripts')
    @include('themes.script')
@stop

Comme la partie script est commune avec la création elle est placée dans une vue partielle :

    <script>

        $(function(){        

            // Number of categories et periods at the begining
            var cat_number = $('#cats .line').length;
            var per_number = $('#pers .line').length; 

            // Delete a line
            $(document).on('click', '.btn-danger', function(){ 
                $(this).parents('.row .line').remove();    
            });

            // Add a categorie line
            $("#add_cat").click(function() {
                var id = 'categorie' + cat_number;
                var html = '<div class="row line" id="line' + id + '">\n<div class="form-group">\n'
                + '<label for="categorie' + cat_number + '" class="col-md-2">Catégorie :</label>\n'
                + '<div class="col-md-8">\n'
                + '{!! Form::select('categories[]', $categories, null, ['class' => 'form-control', 'id' => 'id_temp']) !!}\n'
                + '</div>\n<div class="col-md-2">\n<button type="button" class="btn btn-danger pull-right">Delete</button>\n</div>\n</div>\n';
                ++cat_number;
                $('#cats').append(html);    
                $('#id_temp').attr('id', id);    
            });

            // Add a period line
            $("#add_per").click(function() {
                var id = 'period' + per_number;
                var html = '<div class="row line" id="line' + id + '">\n<div class="form-group">\n'
                + '<label for="period' + per_number + '" class="col-md-2">Période :</label>\n'
                + '<div class="col-md-8">\n'
                + '{!! Form::select('periods[]', $periods, null, ['class' => 'form-control', 'id' => 'id_temp']) !!}\n'
                + '</div>\n<div class="col-md-2">\n<button type="button" class="btn btn-danger pull-right">Delete</button>\n</div>\n</div>\n';
                ++per_number;
                $('#pers').append(html);    
                $('#id_temp').attr('id', id);    
            });

            // Submission 
            $(document).on('submit', 'form', function(e) {  
                e.preventDefault();
                $.ajax({
                    method: $(this).attr('method'),
                    url: $(this).attr('action'),
                    data: $(this).serialize(),
                    dataType: "json"
                })
                .done(function(data) {
                    window.location.href = '{!! url('themes') !!}';
                })
                .fail(function(data) {
                    $.each(data.responseJSON, function (key, value) {
                        if(key == 'name') {
                            $('.help-block').eq(0).text(value);
                            $('.form-group').eq(0).addClass('has-error');                            
                        }                        
                    });
                });
            });

        })

    </script>

Je n’insiste encore pas sur cette partie du code que je vous laisse analyser au besoin.

Au retour il faut mettre la base à jour. Dans le contrôleur on a :

public function update(SharedRequest $request, $id)
{
	$theme = $this->repository->getById($id);

	$this->repository->updateByModel($theme, $request->only('name'));

	$this->repository->syncRelated($theme, $request);

	session()->flash('message_success', $this->message_update);

	return response()->json();
}

On récupère le thème avec son identifiant. On le met à jour avec la méthode updateByModel. La partie intéressante se situe au niveau de la synchronisation des périodes et des catégories dans le repository :

public function syncRelated($theme, $request)
{
	if($request->has('categories'))
	{
		$theme->categories()->sync(array_unique($request->input('categories')));
	}

	if($request->has('periods'))
	{
		$theme->periods()->sync(array_unique($request->input('periods')));
	}		
}

Créer un thème

Pour créer un thème le formulaire est le même que pour la mise à jour sans le renseignement initial. On a juste besoin de remplir les listes de choix. Voici le code dans le contrôleur :

public function create(
    CategoryRepository $categoryRepository,
    PeriodRepository $periodRepository)
{
    $categories = $categoryRepository->getAllSelect();
    $periods = $periodRepository->getAllSelect();

    return view($this->base.'.create', compact('categories', 'periods'));
}

Et au retour :

public function store(SharedRequest $request)
{
    $theme = $this->repository->store($request->only('name'));

    $this->repository->attachRelated($theme, $request);

    session()->flash('message_success', $this->message_store);

    return response()->json();
}

On crée e thème avec la méthode store du repository qui ne pose aucun problème. La partie intéressante se situe au niveau de l’attachement des périodes et des catégories dans le repository :

public function attachRelated($theme, $request)
{
	if($request->has('categories'))
	{
		$theme->categories()->attach(array_unique($request->input('categories')));
	}

	if($request->has('periods'))
	{
		$theme->periods()->attach(array_unique($request->input('periods')));
	}		
}

Supprimer un thème

Pour supprimer un thème il n’y a aucun problème grâce aux cascades prévues.

Conclusion

Je n’ai pas évoqué tout le code mais je me suis attaché à présenter l’essentiel. Vous pouvez analyser le reste. Il doit forcément traîner des bugs, merci de me les signaler. D’autre part j’ai fait un certain nombre de choix de codage qui ne sont pas forcément les meilleurs et il y a sans doute de nombreuse voies d’amélioration. Je me suis posé également pas mal de questions et certaines restent un peu en suspens. Mon seul objectif était de présenter la gestion des relations avec Eloquent avec un panorama assez complet. J’espère y être parvenu.

5 réflexions sur “Les relations avec Eloquent (2/2)

  1. Saradimi dit :

    Bonjour cher bestmomo !
    Je tiens avant tout à vous dire que votre blog est formidable et à été déterminant dans mon choix de framework.
    Il y a une problématique que je rencontre souvent et dont je n’arrive pas à me défaire, compter le nombre de relations d’une liste d’items.
    Prenons un cas précis :
    2 entités : Immeubles et Appartements
    On a donc les 2 relations suivantes :
    Immeuble hasMany Appartement et Appartement belongsTo Immeuble
    Il est donc très facile de compter le nombre d’appart pour un immeuble :
    Immeuble::find(x)->annonces()->count();
    Mais dans le cas ou l’on souhaite compter le nombre total d’appart pour un nombre donné d’immeuble.
    par exemple : Immeuble::where(‘column’,xxx)->annonces()->count();
    Là, bien évidemment, cela ne fonctionne plus.
    Je sais qu’avec des boucles (foreach), on peut incrémenter le nombre d’appart et finir par retrouver le nombre final mais je me demande s’il n’existe pas une meilleure méthode prévue dans les fonctions natives de Laravel.
    Par avance merci de votre aide. Cdt

Laisser un commentaire