Banco de Dados

29 ago, 2017

Fuso Horários no PostgreSQL, Elixir e Phoenix

100 visualizações
Publicidade

Tratar os fusos horários foi difícil em todos os ambientes com os quais eu trabalhei. Parte do problema é que os fusos horários são construções políticas ao invés de geográficas, e, dessa maneira, elas mudam a qualquer momento que as autoridades tomem essa decisão. Isso ocorre com mais frequência do que você acha.

Para mapear os horários locais entre os diferentes fusos horários, nossos computadores precisam de uma base de dados atualizada. Isso normalmente é fornecido pelo pacote tzdata (ou similar) nos sistemas Linux.

Enquanto isso funciona bem para os programas que por definição podem utilizar os dados de fusos horários no nível do sistema operacional, muitos programas ou ambientes de programação decidiram tratar as coisas de maneira diferente. Isso inclui o Elixir – um ambiente multi-plataforma.

Parte da confusão, e também fonte de muitas dificuldades para correção de erros, é o sistema infeliz de tipos SQL.

Como padrão, quando você utiliza o Ecto, a migração das suas bases de dados vai utilizar o tipo timestamp do SQL. Se você se atrever a verificar a documentação do site do PostgreSQL, você vai ver que o timestamp é, na verdade “um campo de hora sem fuso horário”.

O que isso significa? Resumidamente: Esse tipo de dado será interpretado de maneira diferente, dependendo das configurações de fuso horário do cliente que se conecta ao servidor PostgreSQL.

O Ecto utiliza o Postgrex, um driver de conexão com banco de dados para o Elixir, que é diferente de muitos outros drivers para o PostgreSQL que temos. Ao invés de utilizar um protocolo de texto, ele utiliza – maior segurança e performance – protocolo binário.

Um efeito colateral da utilização do protocolo binário é o fato de todos os timestamps serem interpretados como UTC, porque o fuso horário é sempre configurado como UTC.

Sabendo disso, alguém pode assumir que nesse caso suas datas no banco de dados são sempre armazenadas como UTC. Certo?

Errado. Isso será verdade enquanto você estiver conectando o PostgreSQL a partir da sua aplicação Elixir, assim que você conectar utilizando um cliente diferente (psql, driver para Ruby, etc.) os problemas começam a aparecer. Consultas SQL como:

 

SELECT * FROM events WHERE events.start_date < CURRENT_TIMESTAMP;

 

Terão significados diferentes quando você estiver no fuso horário da Europa/ Warshaw, e muito diferente quando você está no fuso horário PDT (Califórnia – EUA), devido às 9 horas de diferença. Se você tem um relatório ou script de manutenção que você executa nesse banco de dados, dependendo das configurações locais do seu cliente, você vai perder mais ou menos eventos da consulta, ou incluir eventos indesejados nos seus resultados. Ele somente estaria correto se seu cliente estiver configurado para utilizar o UTC.

Como posso corrigir esse problema? Utilize o tipo de dados timestampz no PostgreSQL, que é um atalho para timestamp with time zone.

Agora, a nomeação aqui ainda é super confusa. Timestamp with time zone não significa que as entradas no banco de dados carregam informações do fuso horário. Esse tipo assume que o timestamp está no formato UTC.

Em minha humilde opinião, é mais seguro utilizar em sua aplicação Elixir/ Ecto o tipo de dados timestampz, ao invés do timestamps padrão. Para fazer isso, você deveria escrever suas migrações da seguinte forma:

 

create table(:events) do
  add :title, :string
  add :start_date, :timestamptz
  add :end_date, :timestamptz

  timestamps(type: :timestamptz)
end

 

Isso vai garantir que onde quer que você execute sua consulta SQL pelo psql ou pelo Elixir – seus resultados serão os mesmos.

 

Tratando os fusos horários em Elixir

Historicamente, o Elixir (e o Erlang) não tinham tratamento de fusos horários, nem tipos nativos para armazenar essa informação. Quando o Ecto foi desenvolvido, os autores criaram seus próprios tipos de dados, tais como Ecto.DateTime que mapeia os tipos de dados do banco de dados para o tipo personalizado no esquema do Ecto, e seu código Elixir.

Não utilize esses tipos de dados mais. O Elixir agora tem o DateTime embutido, que você pode utilizar.

Existe também o NaiveDateTime. A diferença entre eles é que a versão “Naive” não possui o campo time_zone, o que significa que ele não vai carregar nenhuma informação de fuso horário.

Quando seu banco de dados sempre mantém as informações de data/ hora no formato UTC, eu acho que faz mais sentido utilizar a mesma suposição nos seus esquemas Ecto:

 

schema "events" do
  ...
  field :starts_at, :utc_datetime
  field :ends_at, :utc_datetime
  timestamps(type: :utc_datetime)
end

 

Fazendo isso, você pode assumir que todas as informações de data/ hora em seu esquema Ecto estarão sempre no formato UTC. Os tipos de valores nesses campos serão sempre DataTime com time_zone fixado como UTC sempre que você escrever ou ler os dados.

Então, o que acontece quando você precisa exibir uma data/hora do seu banco de dados para os usuários da sua aplicação web no fuso horário deles?

Receio que você precise de mais alguns pacotes Elixir. O Elixir não vem com muitas funções de calendário, ele somente define as estruturas de dados apropriadas. Funções detalhadas de manipulação de data/ hora são implementadas por bibliotecas externas (Timex ou Calendar).

A minha escolha atualmente se chama simplesmente Calendar. Você vai precisar adicionar ela ao deps:

 

defp deps do
  [  {:calendar, "~> 0.17.2"},  ]
end

 

E também iniciar a aplicação OTP:

 

def application do
  [applications: [:calendar]]
end

 

Nos bastidores, o Calendar utiliza o pacote tzdata, que oferece uma base de dados de fusos horários. Além disso, ele verifica periodicamente mudanças nos fusos horários, e atualiza o banco de dados local para refletir as mudanças. Muito bom.

 

Phoenix e os usuários web

Quando eu quero exibir datas convertidas para o fuso horário local dos meus usuários, eu tento manter uma fronteira entre meu código Elixir e o formulário de dados/ HTML. A suposição é que tudo que esteja da camada “Controller” para baixo terá as datas no formato UTC para facilitar. O que quer que o usuário veja ou envie ao servidor pode estar em seu formato local.

Um colaborador simples para exibir data/ hora no fuso horário local do seu usuário poderia ser o como abaixo:

 

def format_timestamp(nil) do
  nil
end

def format_timestamp(timestamp, time_zone) do
  timestamp
  |> shift_zone!("Europe/Warsaw")
  |> Calendar.Strftime.strftime!("%d/%m/%Y %H:%M")
end

defp shift_zone!(nil, time_zone) do
  nil
end

defp shift_zone!(timestamp, time_zone) do
  timestamp
  |> Calendar.DateTime.shift_zone!(time_zone)
end

 

Eu também juntei um mecanismo para que meus usuários possam especificar o seu fuso horário quando eles adicionam data/ hora aos formulários. Em nosso exemplo, o evento tem data de início e de fim. Meus usuários podem especificar as informações de fuso horário utilizando essa versão melhorada do datetime_select padrão, que aceita os mesmos argumentos e se comporta de maneira similar – ainda tem um menu dropdown para o fuso horário (isso pode ser substituído por um campo oculto). O código para o colaborador:

 

def date_time_and_zone_select(form, field, opts \\ []) do
  time_zone = Keyword.get(opts, :time_zone) || "Etc/UTC"

  value = Keyword.get(opts, :value, Phoenix.HTML.Form.input_value(form, field) || Keyword.get(opts, :default))
          |> shift_zone!(time_zone)


  default_builder = fn b ->
    ~e"""
    <%= b.(:year, []) %> / <%= b.(:month, []) %> / <%= b.(:day, []) %>
    —
    <%= b.(:hour, []) %> : <%= b.(:minute, []) %>
    <%= b.(:time_zone, []) %>
    """
  end

  builder = Keyword.get(opts, :builder) || default_builder

  builder.(datetime_builder(form, field, date_value(value), time_value(value), time_zone, opts))
end

@months [
  {"January", "1"},
  {"February", "2"},
  {"March", "3"},
  {"April", "4"},
  {"May", "5"},
  {"June", "6"},
  {"July", "7"},
  {"August", "8"},
  {"September", "9"},
  {"October", "10"},
  {"November", "11"},
  {"December", "12"},
]

map = &Enum.map(&1, fn i ->
  pre = if i < 9, do: "0"
  {"#{pre}#{i}", i}
end)

@days   map.(1..31)
@hours  map.(0..23)
@minsec map.(0..59)

defp datetime_builder(form, field, date, time, time_zone, parent) do
  id   = Keyword.get(parent, :id, input_id(form, field))
  name = Keyword.get(parent, :name, input_name(form, field))

  fn
    :year, opts when date != nil ->
      {year, _, _}  = :erlang.date()
      {value, opts} = datetime_options(:year, year-5..year+5, id, name, parent, date, opts)
      select(:datetime, :year, value, opts)
    :month, opts when date != nil ->
      {value, opts} = datetime_options(:month, @months, id, name, parent, date, opts)
      select(:datetime, :month, value, opts)
    :day, opts when date != nil ->
      {value, opts} = datetime_options(:day, @days, id, name, parent, date, opts)
      select(:datetime, :day, value, opts)
    :hour, opts when time != nil ->
      {value, opts} = datetime_options(:hour, @hours, id, name, parent, time, opts)
      select(:datetime, :hour, value, opts)
    :minute, opts when time != nil ->
      {value, opts} = datetime_options(:minute, @minsec, id, name, parent, time, opts)
      select(:datetime, :minute, value, opts)
    :second, opts when time != nil ->
      {value, opts} = datetime_options(:second, @minsec, id, name, parent, time, opts)
      select(:datetime, :second, value, opts)
    :time_zone, opts ->
      {value, opts} = timezone_options(:time_zone, parent[:zones_list] || Tzdata.zone_list(), id, name, time_zone, opts)

      if parent[:hide_time_zone] == true do
        hidden_input(:datetime, :time_zone, Keyword.merge(opts, [value: time_zone]))
      else
        select(:datetime, :time_zone, value, opts)
      end
  end
end

defp timezone_options(type, values, id, name, time_zone, opts) do
  suff = Atom.to_string(type)
  {value, opts} = Keyword.pop(opts, :options, values)

  {value,
    opts
    |> Keyword.put_new(:id, id <> "_" <> suff)
    |> Keyword.put_new(:name, name <> "[" <> suff <> "]")
    |> Keyword.put_new(:value, time_zone)}
end


defp datetime_options(type, values, id, name, parent, datetime, opts) do
  opts = Keyword.merge Keyword.get(parent, type, []), opts
  suff = Atom.to_string(type)

  {value, opts} = Keyword.pop(opts, :options, values)

  {value,
    opts
    |> Keyword.put_new(:id, id <> "_" <> suff)
    |> Keyword.put_new(:name, name <> "[" <> suff <> "]")
    |> Keyword.put_new(:value, Map.get(datetime, type))}
end

defp time_value(%{"hour" => hour, "minute" => min} = map),
  do: %{hour: hour, minute: min, second: Map.get(map, "second", 0)}
defp time_value(%{hour: hour, minute: min} = map),
  do: %{hour: hour, minute: min, second: Map.get(map, :second, 0)}

defp time_value(nil),
  do: %{hour: nil, minute: nil, second: nil}
defp time_value(other),
  do: raise(ArgumentError, "unrecognized time #{inspect other}")

defp date_value(%{"year" => year, "month" => month, "day" => day}),
  do: %{year: year, month: month, day: day}
defp date_value(%{year: year, month: month, day: day}),
  do: %{year: year, month: month, day: day}

defp date_value({{year, month, day}, _}),
  do: %{year: year, month: month, day: day}
defp date_value({year, month, day}),
  do: %{year: year, month: month, day: day}

defp date_value(nil),
  do: %{year: nil, month: nil, day: nil}
defp date_value(other),
  do: raise(ArgumentError, "unrecognized date #{inspect other}")   end

 

Isso resulta no formulário enviar um parâmetro extra nos campos de data/ hora, quando for submetido. Ao invés de

 

%{"year" => "2017", "month" => "5", "day" => "1", "hour" => "12", "minute" => "30"}

 

O servidor recebe do formulário o parâmetro extra time­_zone.

 

%{"year" => "2017", "month" => "5", "day" => "1", "hour" => "12", "minute" => "30", "time_zone" => "Europe/Warsaw"}

 

Eu criei um Plug, que recursivamente verifica todos os parâmetros, detecta aqueles mapas personalizados de 6 elementos de data/ hora e substitui eles com o DateTime nativo do Elixir, apropriadamente alterado para o fuso horário local.

O código completo é:

 

defmodule ShiftToUtc do
  @behaviour Plug

  def init([]), do: []

  import Plug.Conn

  def call(%Plug.Conn{} = conn, []) do
    new_params = conn.params |> shift_to_utc!()

    %{conn | params: new_params}
  end

  defp shift_to_utc!(%{__struct__: mod} = struct) when is_atom(mod) do
    struct
  end

  defp shift_to_utc!(%{"year" => year, "month" => month, "day" => day, "hour" => hour, "minute" => minute, "time_zone" => time_zone} = map) do
    {year, _} = Integer.parse(year)
    {month, _} = Integer.parse(month)
    {day, _} = Integer.parse(day)
    {hour, _} = Integer.parse(hour)
    {minute, _} = Integer.parse(minute)

    second = case Map.get(map, "second", 0) do
      0 -> 0
      string ->
        {integer, _} = Integer.parse(string)
        integer
    end

    {{year, month, day}, {hour, minute, second}}
    |> Calendar.DateTime.from_erl!(time_zone)
    |> Calendar.DateTime.shift_zone!("UTC")
  end

  defp shift_to_utc!(%{} = param) do
    Enum.reduce(param, %{}, fn({k, v}, acc) ->
      Map.put(acc, k, shift_to_utc!(v))
    end)
  end

  defp shift_to_utc!(param) when is_list(param) do
    Enum.map(param, &shift_to_utc!/1)
  end

  defp shift_to_utc!(param) do
    param
  end
end

 

Para utilizá-lo, adicione ele aos “Controllers” onde você quer utilizá-lo, ou ao pipeline do navegador no router.ex.

A abordagem alternativa seria escrever um tipo personalizado do Ecto, que alteraria o fuso horário antes de gravar no banco de dados. Um exemplo desse tipo personalizado, gentilmente fornecido pelo Michal Muskala é apresentado abaixo.

 

defmodule ZonedDateTime do
  @behaviour Ecto.Type
  
  def type, do: :utc_datetime
  
  def cast({"time_zone" => time_zone} = map) do
    with {:ok, naive} <- Ecto.Type.cast(:naive_datetime, map),
         {:ok, dt} <- Calendar.DateTime.from_naive(time_zone) do
      {:ok, Calendar.DateTime.shift_zone!(dt, "Etc/UTC")}
    else
      _ -> :error
    end
  end
  def cast(value), do: Ecto.Type.cast(:utc_datetime, value)
  
  def dump(value), do: Ecto.Type.dump(:utc_datetime, value)
  
  def load(value), do: Ecto.Type.load(:utc_datetime, value)
end

 

Data/ Hora com requisitos especiais

 Enquanto as abordagens acima funcionam para mim e meus usuários, você pode ter requisitos ligeiramente diferentes. O caso mais notável, em minha opinião, é quando você precisa preservar a informação de fuso-horário que o usuário especificou.

Por exemplo, você pode querer salvar o fuso horário especificado na tabela de eventos. Como você abordaria isso?

Enquanto os tipos de dados do Elixir permitem que você faça isso, por padrão, o PostgreSQL não permite. A abordagem geral seria armazenar a coluna extra start_date_time_zone em sua tabela de eventos.

Por sorte, não temos que fazer isso manualmente. Existe a biblioteca Calecto, que fornece uma maneira de utilizar o tipo primitivo :calendar_datetime em suas migrações. Nos bastidores, ele cria um tipo PostgreSQL combinado, que armazena as informações sobre data/ hora e fuso horário. Sempre que você salvar informações que contenham data/ hora com fuso horário no banco de dados, a mesma informação salva será retornada depois, quando você utilizar seu esquema para ler as informações.

Isso é especialmente útil quando você quiser se certificar que o start_date do evento não seja alterado no fuso horário local, independente das mudanças de fuso horário. Isso pode importar se seus usuários estiverem na Turquia ou Venezuela, ou as datas estão próximas no futuro.

O outro caso especial são as horas do dia. O PostgreSQL tem um tipo dedicado time (com e sem o fuso horário – é claro) para armazenar informações, como horário de abertura de lojas, etc. Quando você armazena essa informação no formato UTC no banco de dados, você pode ficar em uma situação surpreendente se seus usuários viverem em países que tenham horário de verão. Em tais casos você deve preservar a informação de fuso horário, e garantir que você exiba as informações corretamente.

 

Resumo

Existe muita confusão sobre quais bibliotecas e tipos de dados utilizar quando você estiver escrevendo sua aplicação Elixir.

A abordagem mais simples e que funciona para mim é:

– Utilizar o timestampz no PostgreSQL

– Utilizar o :utc_datetime nos esquemas do Ecto, que mapeia para o DateTime nativo do Elixir com o fuso horário configurado como UTC

– Converter a data/ hora para o fuso horário local quando exibir para o usuário

– Escrever um select personalizado de data/ hora/ fuso horário para substituir o padrão que vem com o Phoenix

– Utilize o Plug personalizado para detectar os parâmetros do formulário que carrega as informações de fuso horário, e transforme-o em UTC antes de enviar para o “Controller”.

 

***

 

Hubert Łępicki faz parte do time de colunistas internacionais do iMasters. A tradução do artigo é feita pela Redação iMasters, com autorização do autor, e você pode acompanhar o artigo em inglês no link: https://www.amberbit.com/blog/2017/8/3/time-zones-in-postgresql-elixir-and-phoenix/