Les apports de DateAndTime dans Rails

Publié le 17 mars 2016 par Nicolas Le Chenic | back

Cet article est publié sous licence CC BY-NC-SA

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.mdd’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.

Date, Time et DateTime

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 !

Active support

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

arborescence core_ext

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!

Problématiques de DateAndTime

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 Timeet Date mais en prenant les particularités de chacune.

La méthode advance

On a vu que dans Rails, Dateretourne 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 yesterdayc’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 ?

DateAndTime au microscope

C’est le moment de détailler les méthodes que nous apporte DateAndTime.

Les boolean

Retourne trueou false en fonction de l’objet courant.

Liste

  • 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?.

Implémentations

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.

Début ou fin de période

Retourne la date de début ou de fin de période en fonction de l’objet courant.

Liste

  • beginning_of_week
  • beginning_of_month
  • beginning_of_year
  • beginning_of_quarter
  • end_of_week
  • end_of_month
  • end_of_year
  • end_of_quarter

Implémentations

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
La méthode 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 Timeou 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.

La méthode 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.

Début et fin de période

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

Liste

  • all_week
  • all_month
  • all_quarter
  • all_year

Implémentation

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_weekqui 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.

Les jours de la semaine

Retourne la date du jour de la semaine correspondant à l’objet courant.

Liste

  • monday
  • sunday

Implémentations

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 avec ago

Retour en arrière de la valeur du paramètre en fonction de l’objet courant.

Liste

  • days_ago(x)
  • weeks_ago(x)
  • month_ago(x)
  • year_ago(x)

Implémentation

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 !

Avancer avec since

La méthode since est simplement l’inverse d’ago, on avancera donc dans le temps en fonction de la valeur du paramètre.

Liste
  • days_since(x)
  • weeks_since(x)
  • month_since(x)
  • year_since(x)

Je vous laisse deviner l’implémentation !

Période précédente ou suivante

Retourne la date de début d’une période précédente ou suivante en fonction de l’objet courant.

Liste

  • 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

Implémentation

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_weeket 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.

Jour de semaine précédent

Retourne le précédent ou le prochain jour de semaine du lundi au vendredi.

Liste

  • prev_weekday (Rails 5)
  • next_weekday (Rails 5)

Implémentation

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.

La méthode 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.

La méthode 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.

Jours écoulés depuis le début de semaine

Enfin, DateAndTimenous offre la possibilité de connaître le nombre de jours écoulés depuis le début de semaine avec days_to_week_start.

Implémentation

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.

Un dernier pour la route

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 DateAndTimemais qui est apporté par la dernière version de Rails.

Implémentation

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.

Conclusion

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.