Pour cet article consacré à la magie de Rails j’ai cherché un sujet d’actualité et pour cela quoi de mieux qu’un petit tour dans les changelogs de Rails ?
Si vous faites un tour du côté du CHANGELOG.md
d’active support vous remarquerez plusieurs ajouts concernant Date
et Time
.
C’est une très bonne occasion pour se rafraîchir les idées sur Date
, Time
et DateTime
, avant de s’intéresser de plus près aux apports du mixin DateAndTime
dans sa version Rails 5 bêta 3.
Time
représente le nombre de secondes écoulées depuis epoch (prononcer époque qui correspond au 1er janvier 1970) et est inclus de base dans ruby.
Date
et DateTime
sont inclus dans la bibliothèque date qui apporte plusieurs fonctionnalités au travers de Date
et de DateTime
.
Voyons voir quelques exemples :
❯ Time.now
2016-03-15 11:39:06 +0100
❯ require "date"
=> true
❯ Date.today
#<Date: 2016-03-15 ((2457463j,0s,0n),+0s,2299161j)>
Pour faire appel à date vous devez faire appel à sa bibliothèque avec require
.
Avec active support Rails ajoute aussi son lot de fonctionnalités et améliorations ainsi :
❯ require "date"
❯ Date.new(2016, 03, 15)
#<Date: 2016-03-15 ((2457463j,0s,0n),+0s,2299161j)>
❯ require "rails"
❯ Date.new(2016, 03, 15)
Tue, 15 Mar 2016
On remarque que Rails modifie le format de la date mais c’est loin d’être son seul apport.
Avant de passer à la suite de cet article je vous conseille d’aller faire un tour du côté des vidéos de Nicolas Cavigneaux sur Hackademy qui traite de ces deux bibliothèques !
Dans cet article on va traiter des fonctionnalités ajoutées à Time
et Date
, il faudra chercher du côté du core_ext
d’active support pour y trouver l’apport de Rails.
Mais au fait, c’est quoi le core_ext
? Et bien ce sont simplement des méthodes qui ont pour but d’étendre les fonctionnalités natives de ruby.
Ainsi au moment de la réalisation de votre application Rails vous bénéficiez de plein de magie sans forcément vous en rendre compte !
Vous souhaitez peut-être profiter de cette magie dans un script qui n’utilise pas Rails ? Pour cela, rien de plus simple.
require 'active_support/core_ext/date'
require 'active_support/core_ext/time'
require 'active_support/core_ext/date_time'
Ici nous bénéficions de nouvelles méthodes sur Date
, Time
et DateTime
qu’active support nous offre gracieusement.
Le core_ext
d’active support ajoute beaucoup d’autres fonctionnalités sur les Array
, les String
, les Integer
, voyez plutôt par vous-même :
On voit dans le core_ext
les modules date
, time
et date_time
mais on remarque aussi string
, array
…
Le module date_and_time
attire peut-être votre attention ? C’est simplement un mixin qui va ajouter des méthodes supportées à la fois par Date
et par Time
!
Quand on réalise un mixin qui est pris en compte par deux classes différentes il faut un peu ruser. Il faudra par exemple créer des méthodes de même noms pour Time
et Date
mais en prenant les particularités de chacune.
On a vu que dans Rails, Date
retourne le jour le mois et l’année alors que Time
prend aussi en compte les heures, minutes et secondes.
Regardons comment Rails gère cette différence :
activesupport/active_support/core_ext/date_and_time/calculation.rb
# Returns a new date/time representing yesterday.
def yesterday
advance(days: -1)
end
activesupport/active_support/core_ext/date/calculation.rb
def advance(options)
options = options.dup
d = self
d = d >> options.delete(:years) * 12 if options[:years]
d = d >> options.delete(:months) if options[:months]
d = d + options.delete(:weeks) * 7 if options[:weeks]
d = d + options.delete(:days) if options[:days]
d
end
activesupport/active_support/core_ext/time/calculation.rb
def advance(options)
unless options[:weeks].nil?
options[:weeks], partial_weeks = options[:weeks].divmod(1)
options[:days] = options.fetch(:days, 0) + 7 * partial_weeks
end
unless options[:days].nil?
options[:days], partial_days = options[:days].divmod(1)
options[:hours] = options.fetch(:hours, 0) + 24 * partial_days
end
d = to_date.advance(options)
d = d.gregorian if d.julian?
time_advanced_by_date = change(:year => d.year, :month => d.month, :day => d.day)
seconds_to_advance = \
options.fetch(:seconds, 0) +
options.fetch(:minutes, 0) * 60 +
options.fetch(:hours, 0) * 3600
if seconds_to_advance.zero?
time_advanced_by_date
else
time_advanced_by_date.since(seconds_to_advance)
end
end
Dans le premier cas on modifie options
dans le contexte de advance
et on retourne la date. Pour yesterday
c’est d = d + options.delete(:days) if options[:days]
qui va modifier le jour retourné.
Lorsqu’on le fait avec Time
on retrouve la méthode advance
de Date
avec d = to_date.advance(options)
ainsi qu’un traitement du temps. C’est plutôt logique au vu des différences retournées par Date
et Time
.
Si on rentre un peu plus dans le détail on remarque ceci :
if options[:days]
options[:days], partial_days = options[:days].divmod(1)
options[:hours] = options.fetch(:hours, 0) + 24 * partial_days
end
Ici options[:days].divmod(1)
nous permet de traiter le reste dans partial_days
ainsi on pourra faire ceci :
❯ Time.now
2016-03-16 14:44:30 +0100
❯ Time.now.advance(days: 0.5)
2016-03-17 04:44:35 +0100
Ici on avance d’une demi-journée soit 12 heures, sympa non ?
C’est le moment de détailler les méthodes que nous apporte DateAndTime
.
Retourne true
ou false
en fonction de l’objet courant.
today?
past?
future?
on_weekend?
(Rails 5)
on_weekday?
(Rails 5)
Les méthodes on_weekend?
et on_weekday?
sont toutes deux ajoutées dans Rails 5 et permettent de savoir si l’objet courant est un jour de semaine on_weekday?
(du lundi au vendredi) ou de week-end on_weekend?
.
Ce sont des méthodes plutôt simples à comprendre regardez plutôt :
activesupport/active_support/core_ext/date_and_time/calculation.rb
WEEKEND_DAYS = [ 6, 0 ]
# Code ...
# Returns true if the date/time is in the future.
def future?
self > self.class.current
end
# Returns true if the date/time falls on a Saturday or Sunday.
def on_weekend?
WEEKEND_DAYS.include?(wday)
end
Dans le cas de future?
une simple comparaison nous permet de déterminer si l’objet courant est supérieur à Time.current
ou Date.current
en fonction du cas.
Pour la méthode on_weekend?
il suffit de savoir que wday
est une méthode de Date
et Time
qui retourne le numéro du jour courant de 0 à 6 en commençant par le dimanche.
Retourne la date de début ou de fin de période en fonction de l’objet courant.
beginning_of_week
beginning_of_month
beginning_of_year
beginning_of_quarter
end_of_week
end_of_month
end_of_year
end_of_quarter
activesupport/active_support/core_ext/date_and_time/calculation.rb
def beginning_of_month
first_hour(change(:day => 1))
end
alias :at_beginning_of_month :beginning_of_month
def beginning_of_quarter
first_quarter_month = [10, 7, 4, 1].detect { |m| m <= month }
beginning_of_month.change(:month => first_quarter_month)
end
alias :at_beginning_of_quarter :beginning_of_quarter
private
def first_hour(date_or_time)
date_or_time.acts_like?(:time) ? date_or_time.beginning_of_day : date_or_time
end
beginning_of_month
La méthode beginning_of_month
appelle la méthode privée first_hour(date_or_time)
qui vérifie si on est en présence de Time
ou de Date
. Dans le premier cas on modifie l’heure, ce qui n’est pas nécessaire dans le second.
Le paramètre passé, quant à lui, fait appel à la méthode change
présente dans Time
et Date
à l’instar d’ advance
et nous permet de modifier la date de l’objet courant en fonction du paramètre ici :day => 1
.
beginning_of_quarter
Enfin beginning_of_quarter
retourne la date de début de trimestre de l’objet courant.
Dans un premier temps on cherche le numéro de mois du début du trimestre en cours. Pour cela on utilise detect
dans tableau décroissant qui arrêtera sa recherche une fois la condition vérifiée. Cette condition devra simplement vérifier que la valeur du tableau est inférieure au mois de l’objet courant.
Il suffit ensuite de changer de mois avec beginning_of_month
.
À noter aussi les nombreux alias de méthodes qui ne seront pas listés dans cet article.
Retourne la date de début et de fin d’une période en fonction de l’objet courant sous forme d’une range.
❯ Time.now.all_week
2016-03-14 00:00:00 +0100..2016-03-20 23:59:59 +0100
all_week
all_month
all_quarter
all_year
activesupport/active_support/core_ext/date_and_time/calculation.rb
def all_week(start_day = Date.beginning_of_week)
beginning_of_week(start_day)..end_of_week(start_day)
end
def all_month
beginning_of_month..end_of_month
end
Ici rien de bien compliqué, on utilise les méthodes vues précédemment, la seule subtilité se situe au niveau de all_week
qui accepte un paramètre qui permet de modifier le jour qui symbolise le début de semaine (lundi par défaut).
❯ Time.now.all_week(:sunday)
2016-03-13 00:00:00 +0100..2016-03-19 23:59:59 +0100
On observe bien le décalage d’une journée par rapport à la valeur par défaut.
Retourne la date du jour de la semaine correspondant à l’objet courant.
monday
sunday
activesupport/active_support/core_ext/date_and_time/calculation.rb
def monday
beginning_of_week(:monday)
end
def beginning_of_week(start_day = Date.beginning_of_week)
result = days_ago(days_to_week_start(start_day))
acts_like?(:time) ? result.midnight : result
end
On remarque que monday
est un sucre syntaxique de beginning_of_week
avec comme particularité qu’il précise le jour de début de semaine. Cette précision est faite, car il est possible de configurer le jour de début de semaine avec config.beginning_of_week
.
La méthode sunday
utilise quant à elle end_of_week
.
Retour en arrière de la valeur du paramètre en fonction de l’objet courant.
days_ago(x)
weeks_ago(x)
month_ago(x)
year_ago(x)
def days_ago(days)
advance(:days => -days)
end
def months_ago(months)
advance(:months => -months)
end
Nous revoilà face à un sucre syntaxique des plus faciles à comprendre !
La méthode since est simplement l’inverse d’ago, on avancera donc dans le temps en fonction de la valeur du paramètre.
days_since(x)
weeks_since(x)
month_since(x)
year_since(x)
Je vous laisse deviner l’implémentation !
Retourne la date de début d’une période précédente ou suivante en fonction de l’objet courant.
prev_day
(Rails 5)
prev_week
(amélioré avec Rails 5)
prev_month
prev_year
prev_quarter
next_day
(Rails 5)
next_week
(amélioré avec Rails 5)
next_month
next_year
next_quarter
def prev_day
advance(days: -1)
end
def prev_week(start_day = Date.beginning_of_week, same_time: false)
result = first_hour(weeks_ago(1).beginning_of_week.days_since(days_span(start_day)))
same_time ? copy_time_to(result) : result
end
alias_method :last_week, :prev_week
La version 5 de Rails ajoute prev_day
et next_day
ainsi que la possibilité de prendre en considération le jour de l’objet courant dans prev_week
et next_week
grâce à same_time: true
.
❯ Time.now.prev_week
2016-03-07 00:00:00 +0100
❯ Time.now.prev_week(same_time: true)
2016-03-07 16:39:16 +0100
❯ Time.now.prev_week(:wednesday, same_time: true)
2016-03-09 16:40:18 +0100
Ici nous pouvons au choix, prendre en compte un jour de la semaine ou l’heure de l’objet courant avec same_time
.
Retourne le précédent ou le prochain jour de semaine du lundi au vendredi.
prev_weekday
(Rails 5)
next_weekday
(Rails 5)
activesupport/active_support/core_ext/date_and_time/calculation.rb
def prev_weekday
if prev_day.on_weekend?
copy_time_to(beginning_of_week(:friday))
else
prev_day
end
end
alias_method :last_weekday, :prev_weekday
def next_weekday
if next_day.on_weekend?
next_week(:monday, same_time: true)
else
next_day
end
end
def copy_time_to(other)
other.change(hour: hour, min: min, sec: sec, usec: try(:usec))
end
Encore une nouveauté apportée par Rails 5, la possibilité de trouver le précédent ou le prochain jour de la semaine en prenant en compte l’heure actuelle.
❯ Time.now.next_weekday
2016-03-17 22:12:36 +0100
Seul les jours dans un intervalle du lundi au vendredi sont pris en compte.
prev_weekday
Si le jour précédent à l’objet courant est compris dans l’intervalle alors on utilise la méthode prev_day
vue précédemment. Sinon on utilisera la méthode privée copy_time_to
qui permettra de prendre en compte l’heure courante qu’on appliquera au précédent vendredi.
next_weekday
Nous avons presque la même chose avec next_weekday
à la différence qu’on utilisera next_week(:monday, same_time: true)
pour récupérer l’heure du prochain lundi.
Enfin, DateAndTime
nous offre la possibilité de connaître le nombre de jours écoulés depuis le début de semaine avec days_to_week_start
.
activesupport/active_support/core_ext/date_and_time/calculation.rb
DAYS_INTO_WEEK = {
:monday => 0,
:tuesday => 1,
:wednesday => 2,
:thursday => 3,
:friday => 4,
:saturday => 5,
:sunday => 6
}
def days_to_week_start(start_day = Date.beginning_of_week)
start_day_number = DAYS_INTO_WEEK[start_day]
current_day_number = wday != 0 ? wday - 1 : 6
(current_day_number - start_day_number) % 7
end
Dans un premier temps on vérifie le jour de début de semaine qui est le lundi par défaut. On utilise ensuite wday
qu’on a vu plus haut pour attribuer un numéro de jour de 0 à 6 en commençant par le lundi.
Il nous suffit ensuite de faire une simple soustraction entre le jour courant et le numéro de début de semaine sur lequel on applique le modulo 7 en cas d’écart supérieur à une semaine.
On arrive à la fin de cet article et pour récompenser les plus courageux je vous propose de découvrir days_in_year
qui n’est pas dans DateAndTime
mais qui est apporté par la dernière version de Rails.
activesupport/active_support/core_ext/time/calculation.rb
COMMON_YEAR_DAYS_IN_MONTH = [nil, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
def days_in_year(year = current.year)
days_in_month(2, year) + 337
end
def days_in_month(month, year = current.year)
if month == 2 && ::Date.gregorian_leap?(year)
29
else
COMMON_YEAR_DAYS_IN_MONTH[month]
end
end
La méthode days_in_year
utilise days_in_month(2, year)
pour récupérer le nombre de jours au mois de février en fonction de l’année year
. Il suffit ensuite d’ajouter les 337 journées que compte le reste de l’année.
Pour ceux qui ne sont pas rassasiés, sachez qu’une suite est prévu. Au programme Time
, Date
, DateTime
mais aussi TimeZone
!
J’espère que cet article aura permis d’éclairer quelques zones d’ombres en démystifiant des méthodes offertes par Rails.
L’équipe Synbioz. Libres d’être ensemble.