Skip to main content

lib/decant/field.ex

defmodule Decant.Field do
  @moduledoc """
  A normalized searchable field.

  Field specs accepted by `Decant.dynamic/2`:

    * `{binding, column}` — match `column` on the named binding `binding`.
    * `{binding, column, opts}` — with per-field `opts`:
      * `:cast` — `:string` wraps the column in `CAST(? AS TEXT)`.
      * `:match` — `:contains | :prefix | :suffix | :exact`, overriding the
        call-wide `:match` for this field only.

  `binding` is the atom passed to `as:` in the host query. `column` is the
  schema field atom.
  """

  @enforce_keys [:binding, :column]
  defstruct binding: nil, column: nil, cast: nil, match: nil

  @type t :: %__MODULE__{
          binding: atom(),
          column: atom(),
          cast: :string | nil,
          match: :contains | :prefix | :suffix | :exact | nil
        }

  @doc "Normalize a user field spec into a `%Decant.Field{}`."
  @spec normalize(tuple() | t()) :: t()
  def normalize(%__MODULE__{} = field), do: validate!(field)

  def normalize({binding, column}) when is_atom(binding) and is_atom(column),
    do: validate!(%__MODULE__{binding: binding, column: column})

  def normalize({binding, column, opts})
      when is_atom(binding) and is_atom(column) and is_list(opts) do
    validate!(%__MODULE__{
      binding: binding,
      column: column,
      cast: Keyword.get(opts, :cast),
      match: Keyword.get(opts, :match)
    })
  end

  def normalize(other) do
    raise ArgumentError,
          "invalid field spec: #{inspect(other)} — expected {binding, column} or {binding, column, opts}"
  end

  defp validate!(%__MODULE__{cast: cast} = f) when cast not in [nil, :string],
    do:
      raise(
        ArgumentError,
        "field #{inspect(f.column)}: :cast must be :string or nil, got: #{inspect(cast)}"
      )

  defp validate!(%__MODULE__{match: match} = f)
       when match not in [nil, :contains, :prefix, :suffix, :exact],
       do:
         raise(
           ArgumentError,
           "field #{inspect(f.column)}: :match must be one of :contains, :prefix, :suffix, :exact, got: #{inspect(match)}"
         )

  defp validate!(%__MODULE__{} = f), do: f
end