defmodule Forage.Codec.Decoder do
@moduledoc """
Functionality to decode a Phoenix `params` map into a form suitable for use
with the query builders and pagination libraries
"""
alias Forage.Codec.Exceptions.InvalidAssocError
alias Forage.Codec.Exceptions.InvalidFieldError
alias Forage.Codec.Exceptions.InvalidSortDirectionError
alias Forage.Codec.Exceptions.InvalidPaginationDataError
alias Forage.ForagePlan
alias Forage.ForagePlan.{
Filter,
Sort,
Pagination
}
@type schema() :: atom()
@type assoc() :: {schema(), atom(), atom()}
@doc """
Encodes a params map into a forage plan (`Forage.ForagerPlan`).
"""
def decode(params, schema) do
filter = decode_filter(params, schema)
sort = decode_sort(params, schema)
pagination = decode_pagination(params, schema)
%ForagePlan{filter: filter, sort: sort, pagination: pagination}
end
@doc """
Extract and decode the filter filters from the `params` map into a list of filters.
## Examples:
TODO
"""
def decode_filter(%{"_filter" => filter_data}, schema) do
decoded_fields =
for {field_string, %{"op" => op, "val" => val}} <- filter_data do
field_or_assoc = decode_field_or_assoc(field_string, schema)
%Filter{field: field_or_assoc, operator: op, value: val}
end
Enum.sort(decoded_fields)
end
def decode_filter(_params, _schema), do: []
def decode_field_or_assoc(field_string, schema) do
parts = String.split(field_string, ".")
case parts do
[field_name] ->
field = safe_field_name_to_atom!(field_name, schema)
{:simple, field}
[local_name, remote_name] ->
assoc = safe_field_names_to_assoc!(local_name, remote_name, schema)
{:assoc, assoc}
_ ->
raise ArgumentError, "Invalid field name '#{field_string}'."
end
end
@doc """
Extract and decode the sort fields from the `params` map into a keyword list.
To be used with a schema.
"""
def decode_sort(%{"_sort" => sort}, schema) do
# TODO: make this more robust
decoded =
for {field_name, %{"direction" => direction}} <- sort do
field_atom = safe_field_name_to_atom!(field_name, schema)
direction = decode_direction(direction)
%Sort{field: field_atom, direction: direction}
end
# Sort the result so that the order is always the same
Enum.sort(decoded)
end
def decode_sort(_params, _schema), do: []
@doc """
Extract and decode the sort fields from the `params` map into a keyword list.
To be used without a schema.
"""
def decode_sort(%{"_sort" => sort}) do
# TODO: make this more robust
decoded =
for {field_name, %{"direction" => direction}} <- sort do
field_atom = safe_field_name_to_atom!(field_name)
direction = decode_direction(direction)
%Sort{field: field_atom, direction: direction}
end
# Sort the result so that the order is always the same
Enum.sort(decoded)
end
def decode_sort(_params), do: []
@doc """
Extract and decode the pagination data from the `params` map into a keyword list.
"""
def decode_pagination(%{"_pagination" => pagination}, _schema) do
decoded_after = pagination["after"]
decoded_before = pagination["before"]
%Pagination{after: decoded_after, before: decoded_before}
end
def decode_pagination(_params, _schema), do: %Pagination{}
@spec decode_direction(String.t() | nil) :: atom() | nil
defp decode_direction("asc"), do: :asc
defp decode_direction("desc"), do: :desc
defp decode_direction(nil), do: nil
defp decode_direction(value), do: raise(InvalidSortDirectionError, value)
def pagination_data_to_integer!(value) do
try do
String.to_integer(value)
rescue
ArgumentError -> raise InvalidPaginationDataError, value
end
end
@doc false
@spec safe_field_names_to_assoc!(String.t(), String.t(), atom()) :: assoc()
def safe_field_names_to_assoc!(local_name, remote_name, local_schema) do
local = safe_assoc_name_to_atom!(local_name, local_schema)
remote_schema = local_schema.__schema__(:association, local).related
remote = safe_field_name_to_atom!(remote_name, remote_schema)
{remote_schema, local, remote}
end
@doc false
def remote_schema(local_name, local_schema) do
local = safe_assoc_name_to_atom!(local_name, local_schema)
remote_schema = local_schema.__schema__(:association, local).related
remote_schema
end
@doc false
def remote_schema_and_field_name_as_atom(local_name_as_string, local_schema) do
local = safe_assoc_name_to_atom!(local_name_as_string, local_schema)
remote_schema = local_schema.__schema__(:association, local).related
{remote_schema, local}
end
@doc false
@spec safe_assoc_name_to_atom!(String.t(), schema()) :: atom()
def safe_assoc_name_to_atom!(assoc_name, schema) do
# This function performs the dangerous job of turning a string into an atom.
schema_associations = schema.__schema__(:associations)
found = Enum.find(schema_associations, fn assoc -> assoc_name == Atom.to_string(assoc) end)
case found do
nil ->
raise InvalidAssocError, {schema, assoc_name}
_ ->
found
end
end
@doc false
@spec safe_field_name_to_atom!(String.t()) :: atom()
def safe_field_name_to_atom!(field_name) do
String.to_existing_atom(field_name)
end
@doc false
@spec safe_field_name_to_atom!(String.t(), schema()) :: atom()
def safe_field_name_to_atom!(field_name, schema) do
schema_fields = schema.__schema__(:fields)
try do
field_name_as_atom = String.to_existing_atom(field_name)
case field_name_as_atom in schema_fields do
true ->
field_name_as_atom
false ->
raise InvalidFieldError, {schema, field_name}
end
rescue
_e in ArgumentError ->
raise InvalidFieldError, {schema, field_name}
end
end
end