Packages
ex_tempo_sql
0.1.0
Ecto types and migration helpers for persisting Tempo intervals and interval sets as PostgreSQL tstzrange/tstzmultirange values.
Current section
Files
Jump to
Current section
Files
lib/tempo/sql/meta.ex
defmodule Tempo.SQL.Meta do
@moduledoc """
JSON encoding and decoding for the `meta` column of the
`tempo_range` and `tempo_multirange` composite types.
The range column of a composite carries the queryable span.
The `meta` column carries everything else Tempo knows that
`tstzrange` cannot express — qualifications, non-Gregorian
calendars, zone identifiers, recurrence rules, interval
metadata, and the implicit-vs-explicit-span distinction.
On store, the meta is a JSON document with this shape:
%{
"v" => 1,
"from" => iso8601_string | null,
"to" => iso8601_string | null,
"recurrence" => pos_integer | "infinity" | 1,
"direction" => 1 | -1,
"duration" => iso8601_string | null,
"repeat_rule" => iso8601_string | null,
"metadata" => map # must be JSON-serialisable
}
On load, the endpoints are reconstituted via
`Tempo.from_iso8601/1`, which faithfully recovers every
Tempo feature ISO 8601 / ISO 8601-2 / IXDTF can express.
Encoding uses Erlang's built-in `:json` module (OTP 27+);
the `jsonb` column type handles storage and indexing on the
Postgres side.
"""
@current_version 1
# Erlang's :json module uses the `:null` atom for JSON null by
# default. We configure both encode and decode to use Elixir's
# `nil` instead, so the rest of the codebase never has to
# distinguish `:null` from `nil`.
@decode_options %{null: nil}
@doc "Encoder callback for `:json.encode/2` that treats `nil` as JSON null."
def nil_aware_encoder(nil, _encode), do: "null"
def nil_aware_encoder(value, encode), do: :json.encode_value(value, encode)
@doc "Decoder options for `:json.decode/3` that map JSON null to `nil`."
def decode_options, do: @decode_options
@doc "Encode an arbitrary term as a nil-aware JSON binary."
def encode_json(term) do
term |> :json.encode(&nil_aware_encoder/2) |> IO.iodata_to_binary()
end
@doc "Decode a nil-aware JSON binary. Returns the decoded term directly."
def decode_json(binary) when is_binary(binary) do
{decoded, :ok, _rest} = :json.decode(binary, :ok, @decode_options)
decoded
end
@doc """
Encode a `t:Tempo.Interval.t/0` to a JSON string for storage
in a `jsonb` column.
Returns an iolist suitable for handing to Postgrex.
"""
@spec encode_interval(Tempo.Interval.t()) :: iodata()
def encode_interval(%Tempo.Interval{} = interval) do
interval
|> interval_to_map()
|> :json.encode(&nil_aware_encoder/2)
end
@doc """
Decode a JSON string (or already-decoded map) back into a
`t:Tempo.Interval.t/0`.
Returns `{:ok, interval}` or `{:error, reason}`.
"""
@spec decode_interval(String.t() | map()) ::
{:ok, Tempo.Interval.t()} | {:error, term()}
def decode_interval(json) when is_binary(json) do
case :json.decode(json, :ok, @decode_options) do
{map, :ok, _rest} when is_map(map) -> decode_interval(map)
{other, :ok, _rest} -> {:error, {:invalid_meta_shape, other}}
end
end
def decode_interval(%{} = map) do
with {:ok, from} <- decode_endpoint(Map.get(map, "from")),
{:ok, to} <- decode_endpoint(Map.get(map, "to")),
{:ok, recurrence} <- decode_recurrence(Map.get(map, "recurrence", 1)),
{:ok, direction} <- decode_direction(Map.get(map, "direction", 1)),
{:ok, duration} <- decode_duration(Map.get(map, "duration")),
{:ok, repeat_rule} <- decode_repeat_rule(Map.get(map, "repeat_rule")) do
{:ok,
%Tempo.Interval{
from: from,
to: to,
recurrence: recurrence,
direction: direction,
duration: duration,
repeat_rule: repeat_rule,
metadata: Map.get(map, "metadata", %{}) || %{}
}}
end
end
# ------------------------------------------------------------------
# Interval → map
# ------------------------------------------------------------------
defp interval_to_map(%Tempo.Interval{} = interval) do
%{
"v" => @current_version,
"from" => encode_endpoint(interval.from),
"to" => encode_endpoint(interval.to),
"recurrence" => encode_recurrence(interval.recurrence),
"direction" => interval.direction,
"duration" => encode_duration(interval.duration),
"repeat_rule" => encode_repeat_rule(interval.repeat_rule),
"metadata" => interval.metadata || %{}
}
end
defp encode_endpoint(:undefined), do: nil
defp encode_endpoint(nil), do: nil
defp encode_endpoint(%Tempo{} = tempo), do: Tempo.to_iso8601(tempo)
defp encode_endpoint(%Tempo.Duration{} = duration), do: Tempo.to_iso8601(duration)
defp encode_recurrence(:infinity), do: "infinity"
defp encode_recurrence(n) when is_integer(n), do: n
defp encode_duration(nil), do: nil
defp encode_duration(%Tempo.Duration{} = d), do: Tempo.to_iso8601(d)
defp encode_repeat_rule(nil), do: nil
defp encode_repeat_rule(%Tempo{} = t), do: Tempo.to_iso8601(t)
# ------------------------------------------------------------------
# Map → Interval fields
# ------------------------------------------------------------------
defp decode_endpoint(nil), do: {:ok, :undefined}
defp decode_endpoint(string) when is_binary(string) do
case Tempo.from_iso8601(string) do
{:ok, %Tempo{} = tempo} -> {:ok, tempo}
{:ok, %Tempo.Duration{} = duration} -> {:ok, duration}
{:ok, other} -> {:error, {:unexpected_endpoint_type, other}}
{:error, _} = err -> err
end
end
defp decode_recurrence(1), do: {:ok, 1}
defp decode_recurrence(n) when is_integer(n) and n > 0, do: {:ok, n}
defp decode_recurrence("infinity"), do: {:ok, :infinity}
defp decode_recurrence(other), do: {:error, {:invalid_recurrence, other}}
defp decode_direction(1), do: {:ok, 1}
defp decode_direction(-1), do: {:ok, -1}
defp decode_direction(other), do: {:error, {:invalid_direction, other}}
defp decode_duration(nil), do: {:ok, nil}
defp decode_duration(string) when is_binary(string) do
case Tempo.from_iso8601(string) do
{:ok, %Tempo.Duration{} = duration} -> {:ok, duration}
{:ok, other} -> {:error, {:unexpected_duration_type, other}}
{:error, _} = err -> err
end
end
defp decode_repeat_rule(nil), do: {:ok, nil}
defp decode_repeat_rule(string) when is_binary(string) do
case Tempo.from_iso8601(string) do
{:ok, %Tempo{} = tempo} -> {:ok, tempo}
{:ok, other} -> {:error, {:unexpected_repeat_rule_type, other}}
{:error, _} = err -> err
end
end
end