Skip to main content

lib/quack_db/ecto/window_frames.ex

if Code.ensure_loaded?(Ecto.Query.API) do
  defmodule QuackDB.Ecto.WindowFrames do
    @moduledoc """
    DuckDB window-frame helpers for Ecto queries.

    Ecto versions before the release containing
    [ecto#4730](https://github.com/elixir-ecto/ecto/pull/4730) only accept literal
    `fragment(...)` calls in a window `:frame` option. These helpers are ready for
    that Ecto release; until your project depends on it, use the generated SQL
    shape directly:

        frame: fragment("ROWS BETWEEN 6 PRECEDING AND CURRENT ROW")

    Once macro-expanded frames are available in your Ecto version, helpers can be
    used as:

        frame: rows_between({:preceding, 6}, :current_row)
        frame: range_between(:unbounded_preceding, {:following, 1})
    """

    @doc "Builds a `ROWS BETWEEN ... AND ...` window frame fragment."
    defmacro rows_between(start_bound, end_bound) do
      frame_fragment("ROWS", start_bound, end_bound)
    end

    @doc "Builds a `RANGE BETWEEN ... AND ...` window frame fragment."
    defmacro range_between(start_bound, end_bound) do
      frame_fragment("RANGE", start_bound, end_bound)
    end

    @doc "Builds a `GROUPS BETWEEN ... AND ...` window frame fragment."
    defmacro groups_between(start_bound, end_bound) do
      frame_fragment("GROUPS", start_bound, end_bound)
    end

    defp frame_fragment(unit, start_bound, end_bound) do
      sql = [unit, " BETWEEN ", frame_bound!(start_bound), " AND ", frame_bound!(end_bound)]
      sql = IO.iodata_to_binary(sql)

      quote do
        fragment(unquote(sql))
      end
    end

    defp frame_bound!(:unbounded_preceding), do: "UNBOUNDED PRECEDING"
    defp frame_bound!(:unbounded_following), do: "UNBOUNDED FOLLOWING"
    defp frame_bound!(:current_row), do: "CURRENT ROW"

    defp frame_bound!({:preceding, count}) when is_integer(count) and count >= 0,
      do: [Integer.to_string(count), " PRECEDING"]

    defp frame_bound!({:following, count}) when is_integer(count) and count >= 0,
      do: [Integer.to_string(count), " FOLLOWING"]

    defp frame_bound!(bound) do
      raise ArgumentError,
            "unsupported window frame bound #{inspect(bound)}; expected :unbounded_preceding, " <>
              ":unbounded_following, :current_row, {:preceding, non_neg_integer}, or " <>
              "{:following, non_neg_integer}"
    end
  end
end