Trier un tableau nativement avec Ruby-On-Rails
20 May 2024 - NororeHaa, les tris de tableau, si utiles d’un point de vue utilisation, et si casse-tête d’un point de vue développement. Sauf si vous êtes dans la catégorie « facilité » où là vous allez juste vous contenter d’utiliser une gem ou une bibliothèque JavaScript toute prête, car après tout, pourquoi s’embêter quand on peut charger directement toutes les lignes, pas vrai ?
Pour ma part, j’aime bien chercher à avoir un code propre et une page web légère. Hashtag boomer nostalgeek du modem 56k ? Ou hashtag neo-écolo-bobo qui découvre qu’une page de 250Mo à charger c’est pas ouf ? Ni l’un ni l’autre, juste une personne qui aime bien avoir une page web légère à proposer à son navigateur web, sa RAM l’en remercie. En plus, ça fait une plus petite consommation de bande passante, votre serveur vous en remercie.
On voit ensemble comment on peut s’y prendre ?
Initialisation du projet Rails
Avant toute chose, je ne vais pas vous faire un tutoriel pas-à-pas de comment initialiser un projet Rails, ni comment développer avec Rails, pour cela je vous invite à lire la documentation officielle ou à voir du côté des nombreux sites qui couvrent ce sujet. Pas de panique, je vous mets quelques sources en fin de billet, si je n’oublie pas d’ici sa publication…
Projet minimal
On va commencer par créer un projet Rails minimal, que je vais appeler sortables, contraction de “sort tables” dont le jeu de mot n’amuse que moi.
rails new sortables --skip-docker --skip-action-mailer --skip-action-mailbox --skip-action-text --skip-active-job --skip-active-storage --skip-js
Pour résumer, pour les néophytes, je vais créer un nouveau projet sans :
- docker (
--skip-docker
) - système d’envoi de mail (
--skip-action-mailer
) - système de réception de mail (
--skip-action-mailbox
) - système d’enrichissement de texte (
--skip-action-text
) - système de job (
--skip-active-job
) - système de stockage de fichier (
--skip-active-storage
) - JavaScript (
--skip-js
)
Si, ultérieurement, je me rends compte que j’ai besoin de l’un ou l’autre, je pourrais toujours les ajouter.
J’en profite pour ajouter la gem faker dans le fichier Gemfile, à la racine de mon projet, dans les groupes
:development
, :test
:
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri mswin mswin64 mingw x64_mingw ]
gem "faker"
end
Cette gem me sera utile par la suite pour générer des fausses données pour le développement. Si vous débutez,
n’oubliez pas de taper bundle
dans votre terminal pour récupérer la gem.
Création de la table
On va commencer par créer une table simple avec quelques colonnes, avec son modèle associé.
# créer les fichiers de migration et de modèle
bin/rails g model People name:string lastname:string country:string city:string hobby:string
# créer le contrôleur
bin/rails g controller People
# créer la base de données et y ajouter la table
bin/rails db:create db:migrate
(Si vous préférez utiliser le générateur scaffold
, libre à vous.)
Pour remplir la table, on va utiliser le système de seeds fournit par le framework. Éditez le fichier db/seeds.rb
:
100.times do
Person.create!(
name: Faker::Name.first_name,
lastname: Faker::Name.last_name,
country: Faker::Address.country,
city: Faker::Address.city,
hobby: Faker::Hobby.activity
)
end
Pour cet exemple, j’ai choisi de générer une table avec une centaine de personnes (100.times
).
Affichage des informations de la table
Pour cet exercice, on va se contenter d’utiliser du HTML brut de décoffrage, pas de fioriture en CSS. Personnellement,
je vais me contenter d’une CSS minimale trouvée sur internet. Nous allons avoir besoin de modifier deux
fichiers et en créer un nouveau. Commençons par le contrôleur app/controller/person_controller.rb
:
class PersonController < ApplicationController
def index
# sélectionner toutes les données de la table
@people = Person.all
end
end
Créons le fichier app/views/person/index.html.erb
:
<table>
<thead>
<tr>
<th>Prénom</th>
<th>Nom</th>
<th>Pays</th>
<th>Ville</th>
<th>Loisir</th>
</tr>
</thead>
<tbody>
<% @people.each do |person| %>
<tr>
<td><%= person.name %></td>
<td><%= person.lastname %></td>
<td><%= person.country %></td>
<td><%= person.city %></td>
<td><%= person.hobby %></td>
</tr>
<% end %>
</tbody>
</table>
Et enfin, n’oublions pas les routes dans config/routes.rb
:
Rails.application.routes.draw do
resources :person
end
On initialise la base de données avant de lancer le serveur rails :
bin/rails db:seed # remplir la base de données
bin/rails s # lancer le serveur rails
Si vous n’avez pas d’erreur, rendez-vous sur http://localhost:3000/person pour voir vos données. Sinon… corrigez !
Félicitations, notre projet est initié ! Nous allons maintenant pouvoir passer à la prochaine étape.
Trier nativement un tableau de données simple
Pour la suite, je vous conseille fortement d’avoir les bases sur l’utilisation des URLs avec paramètres, parce que nous allons jouer avec ! Vous avez suivi le guide officiel ? Vous devriez vous en sortir.
Le tri dans le contrôleur
On a besoin de pouvoir choisir la colonne et l’ordre du tri. On peut le faire facilement avec Active Record, par
exemple, sur la colonne name
:
Person.order(name: :asc) # trier par name et par ordre croissant
Person.order(name: :desc) # trier par name et par ordre décroissant
On pourrait donc préparer notre contrôleur comme suit :
@people = Person.order(name: :asc)
Mais comment faire pour trier depuis le tableau ? Comme indiqué plus haut, en passant par les paramètres d’URL !
Commençons par définir une constante qui indiquera la liste des colonnes autorisées pour le tri :
class PersonController < ApplicationController
ALLOWED_SORTS = %w[name].freeze
end
Cette constante sera utilisée par la suite :
def sort_params
@sort_column = params.fetch :sort, 'name'
render plain: "Illegal sort parameter", status: 400 unless @sort_column.nil? or ALLOWED_SORTS.include? @sort_column
@sort_direction = params.fetch :direction, 'asc'
render plain: "Illegal sort parameter", status: 400 unless %w[asc desc].include? @sort_direction
end
Qu’est-ce que c’est que ce truc ? Oskour!
Commençons par étudier l’assignation de @sort_column
. Si la valeur du paramètre :sort
existe, on l’assigne, sinon,
on assigne 'name'
par défaut. Ensuite, on retourne une erreur de requête (erreur 400) si @sort_column
est vide ou
si sa valeur n’est pas incluse dans la liste définie dans la constante ALLOWED_SORT
.
L’assignation de @sort_direction
suit la même logique.
Donc, par défaut, si vous avez bien suivi, on triera toujours par prénom (colonne name
) dans l’ordre croissant
(asc
). Et si une valeur interdite est saisie dans les paramètres d’URL, une erreur 400 sera levée.
Pour les utiliser, il nous reste à modifier la fonction qui envoie les données au tableau :
@people = Person.order(@sort_column => @sort_direction)
Enfin, on veut pouvoir s’assurer que les paramètres soient légitimes avant de charger notre tableau, pour cela nous
allons ajouter une méthode qui s’exécutera avant toute action : before_action
.
class PersonController < ApplicationController
before_action :sort_params
ALLOWED_SORTS = %w[name lastname country city hobby movie book music sport].freeze
[...]
end
Pour vérifier que cela fait bien le tri, il suffit de tester :
- trié par prénom dans l’ordre décroissant : http://localhost:3000/person?sort=name&direction=desc
- trié par prénom dans l’ordre croissant : http://localhost:3000/person?sort=name&direction=asc
Trier depuis le tableau
Rails apporte un super outil que vous utilisez probablement déjà sans le savoir : la méthode helper. Si vous avez
déjà fait des liens avec la syntaxe officielle, vous avez sûrement utilisé link_to
. Oui, c’est un helper. Il y en a un
certain nombre natif. Mais pour notre cas, nous allons créer le nôtre.
Commençons par éditer le fichier app/helpers/person_helper.rb
pour y mettre notre logique :
module PersonHelper
def sortcustom(column, title = nil)
title ||= column.titleize
direction = (((column == @sort_column) && (@sort_direction == 'asc')) ? 'desc' : 'asc')
link_to title, { sort: column, direction: direction }
end
end
Dans un premier temps, je définis le titre title
, si la variable est définie, elle sera utilisée, sinon, on récupère
la valeur de column
sur laquelle on utilise une fonction interne de Rails pour en faire un titre.
Ensuite, nous définissons la variable direction
avec les valeurs retournées par les variables@sort_column
et
@sort_direction
définies dans le contrôleur (vous ne les avez pas oubliés, n’est-ce pas ?). La
subtilité ici, c’est que nous voulons pouvoir trier par ordre décroissant si l’affichage est trié par ordre croissant,
et inversement, mais seulement si la colonne est celle déjà présente dans l’URL. C’est pour cela que l’on vérifie la
valeur retournée par @sort_direction
pour pouvoir inverser ce qui sera dans link_to
le cas échéant.
En parlant de la partie link_to
… Est-ce que j’ai vraiment besoin de vous décrire ce qu’elle va faire ? C’est
l’helper natif, il n’y a pas de piège ici.
Maintenant, nous allons pouvoir modifier notre tableau pour appeler notre helper personnalisé. Éditons
app/views/person/index.html.erb
:
<th><%= sortcustom "name", "Prénom" %></th>
Oui, c’est aussi simple que ça !
Rafraîchissons notre page web, le tableau apparaît et un lien apparaît sur le titre Préom
. Normalement, vous pouvez
maintenant trier par prénom dans l’ordre croissant ou décroissant.
Mais ça ne marche que sur le prénom, c’est nul…
Est-ce que vous avez déjà oublié la constante ALLOWED_SORT
? C’est elle qui va indiquer au contrôleur quelles sont
les colonnes qui sont autorisées pour le tri ! Une dernière modification à apporter et nous pourrons trier notre
tableau ! Éditons de nouveau le contrôleur :
ALLOWED_SORTS = %w[name lastname country city hobby].freeze
Et n’oublions pas l’entête de notre tableau :
<th><%= sortcustom "name", "Prénom" %></th>
<th><%= sortcustom "lastname", "Nom" %></th>
<th><%= sortcustom "country", "Pays" %></th>
<th><%= sortcustom "city", "Ville" %></th>
<th><%= sortcustom "hobby", "Loisir" %></th>
Trier nativement avec deux tables jointes
Nous y voilà. Cette partie m’a demandé un peu plus de réflexion. Autant pour la première partie, j’ai pu m’en sortir en farfouillant sur le web, en tâtonnant, en testant et en optimisant (encore que… sans l’aide d’aeris la première version de ce code serait un peu plus difficile à comprendre), autant lorsque j’ai eu le cas des jointures, j’ai pris une douche froide.
Ben c’est la même logique, non ?
Oui… Mais non. Nous n’allons pas pouvoir appeler la colonne directement, il va falloir jouer avec les propriétés des associations d’Active Record. Voyons donc ça ensemble dans cette seconde partie.
Nouvelle table associée
Commençons par créer une nouvelle table, les choses préférées de nos personnes :
bin/rails g model Favorites person:belongs_to movie:string book:string music:string sport:string
Ici, la table Favorites aura pour clé étrangère person_id grâce à l’élèment person:belongs_to
. Je vous renvoie au
guide officiel sur https://guides.rubyonrails.org/association_basics.html (en anglais), pour en savoir plus.
Comme je veux pouvoir avoir les choses favorites d’une personne directement accessibles depuis mon modèle Person
, il
faut que je lui dise qu’une personne a une liste de favoris. Nous allons donc modifier le modèle
app/models/person.rb
:
class Person < ApplicationRecord
has_one :favorite
end
N’oublions pas de nourrir cette nouvelle table pour avoir de quoi travailler en modifiant db/seeds.rb
:
Person.all.each do |p|
Favorite.create!(
person_id: p.id,
movie: Faker::Movie.title,
book: Faker::Book.title,
music: Faker::Music.genre,
sport: Faker::Sport.sport
)
end
On va également en profiter pour ajouter les favoris dans notre tableau, en modifiant
app/views/person/index.html.erb
:
<table>
<thead>
<tr>
<th><%= sortcustom "name", "Prénom" %></th>
<th><%= sortcustom "lastname", "Nom" %></th>
<th><%= sortcustom "country", "Pays" %></th>
<th><%= sortcustom "city", "Ville" %></th>
<th><%= sortcustom "hobby", "Loisir" %></th>
<th>Film</th>
<th>Livre</th>
<th>Musique</th>
<th>Sport</th>
</tr>
</thead>
<tbody>
<% @people.each do |person| %>
<tr>
<td><%= person.name %></td>
<td><%= person.lastname %></td>
<td><%= person.country %></td>
<td><%= person.city %></td>
<td><%= person.hobby %></td>
<td><%= person.favorite.movie %></td>
<td><%= person.favorite.book %></td>
<td><%= person.favorite.music %></td>
<td><%= person.favorite.sport %></td>
</tr>
<% end %>
</tbody>
</table>
Nos documents sont prêts, on peut recréer la base de données avec nos modifications et relancer rails :
bin/rails db:drop db:create db:migrate db:seed
bin/rails s
Rien n’a changé pour le tri du tableau, il est toujours fonctionnel.
Trier des tables associées
Avant de se lancer dans le contrôleur, il faut d’abord comprendre comment on va pouvoir trier. Par exemple, si je veux
trier les personnes dans l’ordre décroissant du titre du film favori, il faut, dans un premier temps, que je joigne les
tables ensemble, puis que je trie sur la colonne movie
. Voici comment cela se fait dans la console :
Person.includes(:favorite).order("favorites.movie desc")
Si vous cherchez à trier en appelant directement movie: :desc
, vous aurez une erreur. Si vous voulez avoir une idée
de ce que rails fait, vous pouvez toujours récupérer le premier élèment retourné par la jointure pour vous mettre sur
la piste :
Person.includes(:favorite).first
Person Load (0.2ms) SELECT "people".* FROM "people" ORDER BY "people"."id" ASC LIMIT ? [["LIMIT", 1]]
Favorite Load (0.1ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."person_id" = ? [["person_id", 1]]
OK, maintenant, on a une idée de ce qu’il faut faire, mais comment l’implémenter ? Tout simplement en passant par un
array
. Mettons que je veuille trier par film, la clé du tri pourra être movie
, dont la valeur sera
favorites.movie
. On va tester ça dans le contrôleur app/controller/person_controller.rb
en le modifiant :
class PersonController < ApplicationController
before_action :sort_params
ALLOWED_SORTS = %w[name lastname country city hobby movie].freeze
COLNAME = {'movie' => 'favorites.movie'}.freeze
def index
@people = Person.includes(:favorite).order(COLNAME.fetch(@sort_column, @sort_column) => @sort_direction)
end
[...]
end
On retrouve la méthode fetch()
que nous avons utilisée précédemment. Si la valeur de @sort_column
est dans les clés
de COLNAME
, alors on retourne la valeur associée, sinon, on retourne directement la valeur de @sort_column
.
Pour vérifier que cela marche, on modifie le paramètre sort
dans notre URL locale et on joue avec le paramètre
direction
:
- trié par film dans l’ordre décroissant : http://localhost:3000/person?sort=movie&direction=desc
- trié par film dans l’ordre croissant : http://localhost:3000/person?sort=movie&direction=asc
Si cela ne marche pas, vous avez peut-être oublié d’ajouter movie
dans la liste des colonnes autorisées par le tri
dans la constante ALLOWED_SORT
. Une fois satisfaits du résultat, on peut modifier rapidement notre tableau dans
app/views/person/index.html.erb
:
<th><%= sortcustom "movie", "Film" %></th>
Vous pouvez procéder ainsi pour les autres colonnes. Voici à quoi ressemble notre contrôleur final une fois les colonnes ajoutées :
class PersonController < ApplicationController
before_action :sort_params
ALLOWED_SORTS = %w[name lastname country city hobby movie book music sport].freeze
COLNAME = {
'movie' => 'favorites.movie',
'book'=> 'favorites.book',
'music'=> 'favorites.music',
'sport'=> 'favorites.sport'
}.freeze
def index
@people = Person.includes(:favorite).order(COLNAME.fetch(@sort_column, @sort_column) => @sort_direction)
end
def sort_params
@sort_column = params.fetch :sort, 'name'
render plain: "Illegal sort parameter", status: 400 unless @sort_column.nil? or ALLOWED_SORTS.include? @sort_column
@sort_direction = params.fetch :direction, 'asc'
render plain: "Illegal sort parameter", status: 400 unless %w[asc desc].include? @sort_direction
end
end
Et l’entête de notre tableau final :
<th><%= sortcustom "name", "Prénom" %></th>
<th><%= sortcustom "lastname", "Nom" %></th>
<th><%= sortcustom "country", "Pays" %></th>
<th><%= sortcustom "city", "Ville" %></th>
<th><%= sortcustom "hobby", "Loisir" %></th>
<th><%= sortcustom "movie", "Film" %></th>
<th><%= sortcustom "book", "Livre" %></th>
<th><%= sortcustom "music", "Musique" %></th>
<th><%= sortcustom "sport", "Sport" %></th>
Exercice : traiter des tableaux plus longs
Pour l’instant, nous avons travaillé sur un tableau avec peu de données. Non, 100 lignes, ce n’est pas ce que j’appelle beaucoup de données… Et là, nous avons seulement deux tables simples associées entre elles.
Pour améliorer ce tableau, je vous propose un petit exercice : en jouant seulement avec les paramètres d’URL et Active Record, faites une pagination affichant 25 élèments par page. Vous pouvez également vous amuser à afficher le nombre de pages et des boutons de navigation comme ‘suivant’, ‘précédent’, ‘premier’, ‘dernier’.
Le mot de la fin
Les tris de tableau sont souvent un casse-tête qui est vite résolu, notamment par les débutants, en utilisant des bibliothèques JavaScript, mais ces dernières impliquent souvent de charger l’entiéreté de la table, ce qui peut occasionner de forts ralentissements dans le chargement. La solution la plus sûre, pour alléger les temps de calculs, reste alors de contrôler nous-même la façon dont les données vont être chargées, quittes à devoir se creuser un peu plus les méninges.
Remerciements
Un grand merci à aeris pour la relecture et surtout la revue de code, j’étais sur la bonne piste, mais le code de départ était nettement améliorable. Comme quoi, quand le back rencontre le front, ça fait des échanges utiles !
Pour aller plus loin :
- Guide pour débuter, en anglais
- Formation sur Ruby-On-Rails, proposée par Grafikart, en français
- Documentation sur la méthode fetch de Ruby, en anglais
- En savoir plus sur Action Controller, en anglais
- En savoir plus sur les helpers et comment jouer avec, en anglais
Source de l’image d’accroche :
Différentes sortes de baies (fruits) triées dans des boîtes. On y distingue notamment des groseilles, des myrtilles, des framboises, des fraises. Image par Alex Block, sur le site Unsplash, sous license Unsplash