if Code.ensure_loaded?(Ecto.Type) do defmodule Tempo.Ecto.Interval do @moduledoc """ Ecto.ParameterizedType for persisting a `t:Tempo.Interval.t/0` as a PostgreSQL `tstzrange` value. ## Usage schema "meetings" do field :window, Tempo.Ecto.Interval end and in the migration: add :window, :tstzrange (or use `Tempo.SQL.Migration.add_interval/2`). ## Options * `:resolution` — on load, truncate both endpoints to the given component and drop all sub-components. One of `:year`, `:month`, `:day`, `:hour`, `:minute`, or `:second`. Defaults to `:second` (no truncation). See the storage contract guide for the semantics and caveats. field :reporting_period, Tempo.Ecto.Interval, resolution: :year ## Storage contract This type refuses to store values it cannot round-trip semantically. The `dump` callback returns `:error` for: * Intervals with a recurrence count (`recurrence != 1`) or a `repeat_rule`. Materialise into a `t:Tempo.IntervalSet.t/0` via `Tempo.to_interval/1` and store the set as `Tempo.Ecto.IntervalSet` instead. * Tempo endpoints with a `:qualification` (`:uncertain`, `:approximate`), a non-Gregorian calendar, or multi-valued token slots (`day_of_week: [1, 3, 5]`, `day: 1..15`). * Intervals that are unbounded on both sides. Partial open ends (`from: :undefined` or `to: :undefined`) are stored as `(,b]` / `[a,)` ranges in Postgres. ## Bracket normalisation on load PostgreSQL does not canonicalise `tstzrange` values on output — a column populated by another writer might hold `[a, b]`, `(a, b)`, or `(a, b]`. The loader normalises any non-half-open range to Tempo's `[first, last)` convention by shifting the offending endpoint one second (Tempo is second-resolution so this is exact). """ use Ecto.ParameterizedType alias Ecto.ParameterizedType alias Tempo.SQL.Conversion @doc """ Helper for use outside a schema — returns a `t:Ecto.ParameterizedType.opts/0` tuple that can be passed to `Ecto.Type.cast/2` etc. ### Examples params = Tempo.Ecto.Interval.cast_type(resolution: :year) Ecto.Type.cast(Tempo.Ecto.Interval, range, params) """ def cast_type(options \\ []) do ParameterizedType.init(__MODULE__, options) end @impl Ecto.ParameterizedType def type(_params), do: :tstzrange @impl Ecto.ParameterizedType def init(options) do resolution = options |> Keyword.get(:resolution, :second) |> Conversion.validate_resolution!() %{resolution: resolution} end @impl Ecto.ParameterizedType def cast(nil, _params), do: {:ok, nil} def cast(%Tempo.Interval{} = interval, _params), do: materialise(interval) def cast(%Tempo{} = tempo, _params), do: materialise(tempo) def cast(%Postgrex.Range{} = range, params) do case Conversion.range_to_interval(range, resolution: params.resolution) do {:ok, interval} -> {:ok, interval} {:error, _} -> :error end end def cast(_, _params), do: :error @impl Ecto.ParameterizedType def load(value, loader \\ nil, params \\ %{resolution: :second}) def load(nil, _loader, _params), do: {:ok, nil} def load(%Postgrex.Range{} = range, _loader, params) do case Conversion.range_to_interval(range, resolution: params.resolution) do {:ok, interval} -> {:ok, interval} {:error, _} -> :error end end def load(_, _, _), do: :error @impl Ecto.ParameterizedType def dump(value, dumper \\ nil, params \\ %{resolution: :second}) def dump(nil, _, _), do: {:ok, nil} def dump(%Tempo.Interval{} = interval, _, _) do case Conversion.interval_to_range(interval) do {:ok, range} -> {:ok, range} {:error, _} -> :error end end def dump(_, _, _), do: :error @impl Ecto.ParameterizedType def equal?(a, b, _params), do: a == b @impl Ecto.ParameterizedType def embed_as(_format, _params), do: :self defp materialise(%Tempo.Interval{from: %Tempo{}, to: %Tempo{}} = interval), do: {:ok, interval} defp materialise(value) do case Tempo.to_interval(value) do {:ok, %Tempo.Interval{} = interval} -> {:ok, interval} {:ok, %Tempo.IntervalSet{}} -> :error _ -> :error end end end end