Blog de Norore
Geek en perdition dans un monde qui va trop vite

Trier un tableau nativement avec Ruby-On-Rails

20 May 2024 - Norore
Différentes sortes de baies (fruits) triées dans des boîtes. On y distingue notamment des groseilles, des myrtilles, des framboises, des fraises.

Haa, 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 :

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 :

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 :

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