Current section
Files
Jump to
Current section
Files
lib/pageantry/page.ex
defmodule Pageantry.Page do
@moduledoc """
Combination of user preferences, user input, validation requirements,
result output, and the Ecto query for generating the output
of a paged/sorted/filtered list.
## Fields
* `query`
: Ecto query for generating output.
* `prefs`
: User preferences.
* `input`
: User input.
* `validation`
: Validation definitions and restrictions to apply when adding user input
to base query.
* `output`
: Output results.
"""
require Ecto.Query
alias Ecto.{Query, Repo, Schema}
alias Pageantry.{Cast, Field, FieldRelation, InetType, Input, Output, Prefs, Validation}
defstruct [
:repo,
:query,
prefs: %Prefs{},
input: %Input{},
validation: %Validation{},
output: %Output{}
]
@type t :: %__MODULE__{
repo: Repo.t(),
query: Query.t(),
prefs: Prefs.t(),
input: Input.t(),
validation: Validation.t(),
output: Output.t()
}
@doc """
Creates new Page struct with all defaults.
## Examples
iex> import Pageantry.Page
iex> new()
%Pageantry.Page{}
"""
@spec new() :: __MODULE__.t()
def new do
%__MODULE__{}
end
@doc """
Creates new Page struct with specified prefs.
## Examples
iex> import Pageantry.Page
iex> new(%Pageantry.Prefs{tz: "America/New_York"})
%Pageantry.Page{prefs: %Pageantry.Prefs{tz: "America/New_York"}}
"""
@spec new(Prefs.t()) :: __MODULE__.t()
def new(prefs) do
%__MODULE__{prefs: prefs}
end
@doc """
Creates new Page struct with prefs and parses params.
## Examples
iex> import Pageantry.Page
iex> page = new(%Pageantry.Prefs{tz: "America/New_York"}, %{"off" => "100"})
iex> page.prefs.tz
"America/New_York"
iex> page.input.off
100
"""
@spec new(Prefs.t(), map) :: __MODULE__.t()
def new(prefs, params) do
__MODULE__.new(prefs) |> __MODULE__.parse(params)
end
@doc """
Creates new Page struct with input defaults.
## Examples
iex> import Pageantry.Page
iex> input(100).input
%Pageantry.Input{off: 0, max: 100, sort: [], filter: []}
"""
@spec input(integer) :: __MODULE__.t()
def input(max) do
%__MODULE__{input: Input.new(max)}
end
@doc """
Creates new Page struct with input defaults.
## Examples
iex> import Pageantry.Page
iex> input(100, name: "foo").input
%Pageantry.Input{max: 100, filter: [name: "foo"]}
iex> input(100, [desc: :created], [created: "today"]).input
%Pageantry.Input{off: 0, max: 100, sort: [desc: :created], filter: [created: "today"]}
"""
@spec input(integer, keyword, keyword) :: __MODULE__.t()
def input(max, sort \\ [], filter) do
%__MODULE__{input: Input.new(max, sort, filter)}
end
@doc """
Creates new Page struct with parsed user input.
## Examples
iex> import Pageantry.Page
iex> parse(%{"max" => "100", "field" => "name", "q" => "foo"}).input
%Pageantry.Input{max: 100, filter: [name: "foo"]}
"""
@spec parse(map) :: __MODULE__.t()
def parse(params) do
%__MODULE__{} |> parse(params)
end
@doc """
Adds parsed user input to page struct.
## Examples
iex> import Pageantry.Page
iex> parse(input(100), %{"field" => "name", "q" => "foo"}).input
%Pageantry.Input{max: 100, filter: [name: "foo"]}
"""
@spec parse(__MODULE__.t(), map) :: __MODULE__.t()
def parse(page, params) do
%{page | input: Input.parse(page.input, params, page.prefs)}
end
@doc """
Adds validation definitions and restrictions to page struct.
## Examples
iex> import Pageantry.Page
iex> v = %Pageantry.Validation{max_max: 100, base_filter: [active: true]}
iex> page = new() |> put_validation(v)
iex> page.validation.max_off
0
iex> page.validation.max_max
100
iex> page.validation.base_filter
[active: true]
"""
@spec put_validation(__MODULE__.t(), Validation.t()) :: __MODULE__.t()
def put_validation(page, validation) do
%{page | validation: validation}
end
@doc """
Adds user prefs to page struct.
## Examples
iex> import Pageantry.Page
iex> page = new() |> put_prefs(%Pageantry.Prefs{tz: "America/New_York"})
iex> page.prefs.weekstart
:sun
iex> page.prefs.tz
"America/New_York"
"""
@spec put_prefs(__MODULE__.t(), Prefs.t()) :: __MODULE__.t()
def put_prefs(page, prefs) do
%{page | prefs: prefs}
end
@doc """
Adds Ecto repo to page struct.
"""
@spec put_repo(__MODULE__.t(), Repo.t()) :: __MODULE__.t()
def put_repo(page, repo) do
%{page | repo: repo}
end
@doc """
Adds Ecto query to page struct.
"""
@spec put_query(__MODULE__.t(), Query.t()) :: __MODULE__.t()
def put_query(page, query) when is_atom(query) do
page =
if page.validation.schema do
page
else
put_validation(page, query.default_page_validation())
end
put_query(page, query.query_builder().from())
end
def put_query(page, query) do
%{page | query: query}
end
@doc """
Adds Ecto repo and query to page struct.
"""
@spec put_query(__MODULE__.t(), Repo.t(), Query.t()) :: __MODULE__.t()
def put_query(page, repo, query) do
page
|> put_repo(repo)
|> put_query(query)
end
@doc """
Updates query in page struct with paging/sorting/filtering from input,
executes query, and updates output struct with result.
"""
@spec query(__MODULE__.t()) :: __MODULE__.t()
# sobelow_skip ["SQL.Query"]
def query(page) do
page
|> join()
|> where()
|> order_by()
|> offset()
|> limit()
|> count()
|> select()
end
@doc """
Updates page struct with the specified query,
updates query with paging/sorting/filtering from input,
executes query, and updates output struct with result.
"""
@spec query(__MODULE__.t(), Query.t()) :: __MODULE__.t()
# sobelow_skip ["SQL.Query"]
def query(page, query) do
page
|> put_query(query)
|> query()
end
@doc """
Updates page struct with the specified repo and query,
updates query with paging/sorting/filtering from input,
executes query, and updates output struct with result.
"""
@spec query(__MODULE__.t(), Repo.t(), Query.t()) :: __MODULE__.t()
# sobelow_skip ["SQL.Query"]
def query(page, repo, query) do
page
|> put_query(repo, query)
|> query()
end
@doc """
Updates page struct with the specified repo and query and validation,
updates query with paging/sorting/filtering from input,
executes query, and updates output struct with result.
"""
@spec query(__MODULE__.t(), Repo.t(), Query.t(), Validation.t()) :: __MODULE__.t()
# sobelow_skip ["SQL.Query"]
def query(page, repo, query, validation) do
page
|> put_validation(validation)
|> query(repo, query)
end
@doc """
Updates page struct with the specified repo and query and validation
and request parameters, updates query with paging/sorting/filtering from input,
executes query, and updates output struct with result.
"""
@spec query(__MODULE__.t(), Repo.t(), Query.t(), Validation.t(), map) :: __MODULE__.t()
# sobelow_skip ["SQL.Query"]
def query(page, repo, query, validation, params) do
page
|> parse(params)
|> query(repo, query, validation)
end
@doc """
Updates page struct with the specified repo and query and validation
and request parameters and user prefs,
updates query with paging/sorting/filtering from input,
executes query, and updates output struct with result.
"""
@spec query(__MODULE__.t(), Repo.t(), Query.t(), Validation.t(), map, Prefs.t()) ::
__MODULE__.t()
# sobelow_skip ["SQL.Query"]
def query(page, repo, query, validation, params, prefs) do
page
|> put_prefs(prefs)
|> query(repo, query, validation, params)
end
@doc """
Maps each output item with the specified function and updates the output struct.
## Examples
iex> import Pageantry.Page
iex> page = %Pageantry.Page{output: %Pageantry.Output{items: [1, 2, 3]}}
iex> map(page, &to_string/1).output.items
["1", "2", "3"]
"""
@spec map(__MODULE__.t(), fun) :: __MODULE__.t()
def map(page, fun) do
put_in(page.output.items, Enum.map(page.output.items, fun))
end
@doc """
Executes count query and updates output total with the result.
"""
@spec count(__MODULE__.t()) :: __MODULE__.t()
def count(page) do
total =
page.query
|> Query.exclude(:limit)
|> Query.exclude(:offset)
|> Query.exclude(:order_by)
|> Query.exclude(:preload)
|> Query.exclude(:select)
|> Query.select(count())
|> page.repo.one()
put_in(page.output.total, total)
end
@doc """
Executes select query and updates output items with the result.
"""
@spec select(__MODULE__.t()) :: __MODULE__.t()
def select(page) do
items =
case page.output.total do
0 -> []
_ -> page.repo.all(page.query)
end
put_in(page.output.items, items)
end
@doc """
Adds join clause from user input and validation requirements to Ecto query.
"""
@spec join(__MODULE__.t()) :: __MODULE__.t()
def join(page) do
%__MODULE__{input: input, validation: validation} = page
%Validation{schema: schema, fields: fields} = validation
# starting with all field definitions
query =
fields
# filter out fields that don't need joins
|> Enum.filter(fn {name, field} ->
should_join_for_field?(name, field, input, validation)
end)
# convert to list of {from schema, to schema} tuples
|> Enum.flat_map(fn {_, field} -> list_joins_for_field(schema, field.relation) end)
# filter out duplicate {from, to} pairs
|> Enum.uniq()
# filter out joins already added manually to query
|> Enum.filter(fn {_, to} -> !page.query.aliases[schema_name_to_atom(to)] end)
# add a left join for each {from, to} pair
|> Enum.reduce(page.query, fn {from, to}, query ->
from.query_builder().join(query, to, :left)
end)
%{page | query: query}
end
@spec should_join_for_field?(atom, Field.t(), Input.t(), Validation.t()) :: boolean
defp should_join_for_field?(name, field, input, validation) do
field_is_in_joinable_table?(field, validation) &&
(field_is_being_used_for_all_filter?(field, input) ||
field_is_being_used_for_filter?(name, field, input, validation) ||
field_is_being_used_for_sort?(name, field, input, validation))
end
@spec field_is_in_joinable_table?(Field.t(), Validation.t()) :: boolean
defp field_is_in_joinable_table?(field, validation) do
field.relation && calc_relation_cardinality(validation.schema, field.relation) == :one
end
@spec field_is_being_used_for_all_filter?(Field.t(), Input.t()) :: boolean
defp field_is_being_used_for_all_filter?(field, input) do
field.filter && field.all && Keyword.has_key?(input.filter, :ALL)
end
@spec field_is_being_used_for_filter?(atom, Field.t(), Input.t(), Validation.t()) :: boolean
defp field_is_being_used_for_filter?(name, field, input, validation) do
field.filter &&
(Keyword.has_key?(input.filter, name) || Keyword.has_key?(validation.base_filter, name))
end
@spec field_is_being_used_for_sort?(atom, Field.t(), Input.t(), Validation.t()) :: boolean
defp field_is_being_used_for_sort?(name, field, input, validation) do
field.sort &&
(name in Keyword.values(input.sort) || name in Keyword.values(validation.base_sort))
end
@spec list_joins_for_field(Schema.t(), FieldRelation.t()) :: list({Schema.t(), Schema.t()})
defp list_joins_for_field(_, nil), do: []
defp list_joins_for_field(schema, relation) do
association = schema.__schema__(:association, relation.association)
related_schema = association.related
# list {from schema, to schema} recursively
[{schema, related_schema} | list_joins_for_field(related_schema, relation.relation)]
end
@doc """
Adds where clause from user input and validation requirements to Ecto query.
"""
@spec where(__MODULE__.t()) :: __MODULE__.t()
def where(page) do
%__MODULE__{prefs: prefs, input: input, validation: validation} = page
%Validation{schema: schema, fields: fields, base_filter: base_filter} = validation
# HACK: store repo on each field for use by build_subquery_condition()
fields = Enum.map(fields, fn {key, value} -> {key, Map.put(value, :repo, page.repo)} end)
# build list of fields eligible for "all" filter
conditions =
calc_all_filter(page)
# convert to list of {Schema, Field, value} tuples, with value casted to field type
|> convert_filter_to_conditions(fields, schema, prefs)
# combine with SQL OR clauses
|> Enum.reduce(nil, &or_conditions/2)
# combine "all" filter with base filter and named-field input via ANDs
# (do this second to make sure ORs from above are combined to this via an AND)
conditions =
(input.filter ++ base_filter)
# convert to list of {Schema, Field, value} tuples, with value casted to field type
|> convert_filter_to_conditions(fields, schema, prefs)
# combine with SQL AND clauses
|> Enum.reduce(conditions, &and_conditions/2)
case conditions do
nil -> page
_ -> %{page | query: Query.where(page.query, ^conditions)}
end
end
@spec calc_all_filter(__MODULE__.t()) :: list
defp calc_all_filter(%__MODULE__{input: input, validation: validation}) do
case input.filter[:ALL] do
nil ->
[]
# build list of all fields elligible for "all" filter
# each set to the same "all" value
value ->
validation.fields
|> Enum.filter(fn {_, field} -> field.all end)
|> Enum.map(fn {k, _} -> {k, value} end)
end
end
@spec convert_filter_to_conditions(keyword(atom), keyword(Field.t()), Schema.t(), Prefs.t()) ::
Query.dynamic()
defp convert_filter_to_conditions(filter, fields, schema, prefs) do
# starting with keyword-list of user input filter
# like [name: "foo", created: "today"]
filter
# replace each field-name key with Pageantry.Field definition struct
|> Enum.map(fn {field_name, value} -> {fields[field_name], value} end)
# filter out fields that are not eligible (eg filter=false)
|> Enum.filter(fn {field, _} -> field && field.filter end)
# add type to tuple
|> Enum.map(fn {field, value} ->
type = resolve_field_filter_type(field.field, schema, field.relation, field.filter)
{field, value, type}
end)
# cast each field value to schema-defined type
|> Enum.map(fn {field, value, type} ->
{schema, field, Cast.cast_to_type(type, value, prefs), type}
end)
# filter out fields with values that could not be casted
|> Enum.filter(fn {_, _, value, _} -> value != nil end)
# convert field tuples to Query.dynamic() conditions
|> Enum.map(&build_filter_condition/1)
end
@spec build_filter_condition({Schema.t(), Field.t(), term, term}) :: Query.dynamic()
defp build_filter_condition({schema, %Field{relation: relation} = field, value, type}) do
case calc_relation_cardinality(schema, relation) do
:one -> build_query_condition(schema, field, value, type)
:many -> build_subquery_condition(schema, field, value, type)
end
end
@spec calc_relation_cardinality(Schema.t(), FieldRelation.t()) :: :one | :many
defp calc_relation_cardinality(_, nil), do: :one
defp calc_relation_cardinality(schema, relation) do
association = schema.__schema__(:association, relation.association)
case association.cardinality do
:one -> calc_relation_cardinality(association.related, relation.relation)
:many -> :many
end
end
@spec build_query_condition(Schema.t(), Field.t(), term, term) :: Query.dynamic()
defp build_query_condition(schema, field, %{first: lo, last: hi}, _type) do
{field_name, source} = resolve_schema_field(field, schema)
Query.dynamic([{^source, x}], field(x, ^field_name) >= ^lo and field(x, ^field_name) <= ^hi)
end
defp build_query_condition(schema, field, value, {:array, InetType}) do
{field_name, source} = resolve_schema_field(field, schema)
Query.dynamic([{^source, x}], fragment("? <<= ANY(?)", ^value, field(x, ^field_name)))
end
defp build_query_condition(schema, field, value, {:array, _}) do
{field_name, source} = resolve_schema_field(field, schema)
Query.dynamic([{^source, x}], fragment("? = ANY(?)", ^value, field(x, ^field_name)))
end
defp build_query_condition(schema, %Field{filter: :boolean} = field, value, _type) do
{field_name, source} = resolve_schema_field(field, schema)
Query.dynamic(
[{^source, x}],
coalesce(type(field(x, ^field_name), :boolean), false) == ^value
)
end
defp build_query_condition(schema, %Field{filter: :like} = field, value, _type) do
{field_name, source} = resolve_schema_field(field, schema)
case Regex.run(~r/^"(.*)"$/, value) do
[_, exact] ->
Query.dynamic([{^source, x}], field(x, ^field_name) == ^exact)
_ ->
Query.dynamic([{^source, x}], ilike(field(x, ^field_name), ^"%#{value}%"))
end
end
defp build_query_condition(schema, field, value, InetType) do
{field_name, source} = resolve_schema_field(field, schema)
Query.dynamic([{^source, x}], fragment("? <<= ?", ^value, field(x, ^field_name)))
end
defp build_query_condition(schema, field, value, _type) do
{field_name, source} = resolve_schema_field(field, schema)
Query.dynamic([{^source, x}], field(x, ^field_name) == ^value)
end
@spec build_subquery_condition(Schema.t(), Field.t(), term, term) :: Query.dynamic()
defp build_subquery_condition(schema, %Field{relation: relation} = field, value, type) do
source = schema_name_to_atom(schema)
association = schema.__schema__(:association, relation.association)
related_schema = association.related
related_key = association.related_key
key = association.owner_key
subq =
related_schema.query_builder().from()
|> Query.select([x], field(x, ^related_key))
|> join_subquery_condition(related_schema, relation.relation)
|> Query.where(^build_query_condition(schema, field, value, type))
|> Query.distinct(true)
|> field.repo.all()
# HACK: when ecto supports subqueries in Query.dynamic(),
# then repo.all() can be removed from the pipeline and this would work:
# Query.dynamic([{^source, x}], field(x, ^key) in subquery(subq))
Query.dynamic([{^source, x}], field(x, ^key) in ^subq)
end
@spec join_subquery_condition(Query.t(), Schema.t(), FieldRelation.t()) :: Query.t()
defp join_subquery_condition(query, _, nil), do: query
defp join_subquery_condition(query, schema, relation) do
association = schema.__schema__(:association, relation.association)
related_schema = association.related
query
|> schema.query_builder().join(related_schema, :inner)
|> join_subquery_condition(related_schema, relation.relation)
end
@spec and_conditions(Query.dynamic(), term) :: Query.dynamic()
defp and_conditions(cond1, nil), do: and_conditions(cond1, true)
defp and_conditions(cond1, cond2), do: Query.dynamic(^cond1 and ^cond2)
@spec or_conditions(Query.dynamic(), term) :: Query.dynamic()
defp or_conditions(cond1, nil), do: or_conditions(cond1, false)
defp or_conditions(cond1, cond2), do: Query.dynamic(^cond1 or ^cond2)
@doc """
Adds order by from user input and validation requirements to Ecto query.
"""
@spec order_by(__MODULE__.t()) :: __MODULE__.t()
def order_by(page) do
# map user input and base sort to list of {order, field_name, schema_name} triples
# (filtering out those ineligible for sorting)
query =
calc_order_by(page)
# apply Ecto.Query.order_by() to each field
|> Enum.reduce(page.query, &apply_order_by(&2, &1))
%{page | query: query}
end
@spec calc_order_by(__MODULE__.t()) :: list
defp calc_order_by(%__MODULE__{input: input, validation: validation}) do
(input.sort ++ validation.base_sort)
# look up each field in validation.fields keyword-list
|> Enum.map(fn {order, field_name} -> {order, validation.fields[field_name]} end)
# filter out fields not in validation.fields keyword-list, or that are sort=false
|> Enum.filter(fn {_, field} -> field && field.sort end)
# convert each {order, field} pair to a {order, field_name, schema_name} triple
|> Enum.map(fn {order, field} ->
resolve_schema_field(field, validation.schema) |> Tuple.insert_at(0, order)
end)
end
@spec apply_order_by(Query.t(), {atom, atom, atom}) :: Query.t()
defp apply_order_by(query, {:asc, field_name, source}) do
apply_order_by(query, {:asc_nulls_first, field_name, source})
end
defp apply_order_by(query, {:desc, field_name, source}) do
apply_order_by(query, {:desc_nulls_last, field_name, source})
end
defp apply_order_by(query, {order, field_name, source}) do
Query.order_by(query, [{^source, x}], [{^order, field(x, ^field_name)}])
end
@spec resolve_schema_field(Field.t(), Schema.t()) :: {atom, atom}
defp resolve_schema_field(%Field{relation: relation, field: field_name}, schema) do
{field_name, resolve_schema_name(schema, relation)}
end
@spec resolve_schema_name(Schema.t(), FieldRelation.t()) :: :atom
defp resolve_schema_name(schema, nil), do: schema_name_to_atom(schema)
defp resolve_schema_name(schema, relation) do
association = schema.__schema__(:association, relation.association)
resolve_schema_name(association.related, relation.relation)
end
@spec schema_name_to_atom(Schema.t()) :: atom
# to_atom is safe because it's converting the defined schema name
# sobelow_skip ["DOS.StringToAtom"]
defp schema_name_to_atom(schema) do
String.to_atom(schema.__schema__(:source))
end
@doc """
Adds offset from user input and validation requirements to Ecto query.
"""
@spec offset(__MODULE__.t()) :: __MODULE__.t()
def offset(page) do
query = Query.offset(page.query, ^calc_offset(page))
%{page | query: query}
end
@spec calc_offset(__MODULE__.t()) :: integer
defp calc_offset(%__MODULE__{input: input, validation: validation}) do
cond do
input.off < 0 -> 0
input.off > validation.max_off and validation.max_off > 0 -> validation.max_off
true -> input.off
end
end
@doc """
Adds limit from user input and validation requirements to Ecto query.
"""
@spec limit(__MODULE__.t()) :: __MODULE__.t()
def limit(page) do
query = Query.limit(page.query, ^calc_limit(page))
%{page | query: query}
end
@spec calc_limit(__MODULE__.t()) :: integer
defp calc_limit(%__MODULE__{input: input, validation: validation}) do
cond do
input.max < 1 -> 1
input.max > validation.max_max and validation.max_max > 0 -> validation.max_max
true -> input.max
end
end
@doc """
Filters, sorts, and slices output items.
"""
@spec slice(__MODULE__.t()) :: __MODULE__.t()
def slice(page) do
page
|> filter_output()
|> sort_output()
|> slice_output()
end
@doc """
Updates page struct with the specified output items, and filters, sort, and slices the items.
"""
@spec slice(__MODULE__.t(), list) :: __MODULE__.t()
def slice(page, items) do
page
|> put_output(items)
|> slice()
end
@doc """
Updates page struct with the specified output items and validation,
and filters, sort, and slices the items.
"""
@spec slice(__MODULE__.t(), list, Validation.t()) :: __MODULE__.t()
def slice(page, items, validation) do
page
|> put_validation(validation)
|> slice(items)
end
@doc """
Updates page struct with the specified output items and validation and request parameters,
and filters, sort, and slices the items.
"""
@spec slice(__MODULE__.t(), list, Validation.t(), map) :: __MODULE__.t()
def slice(page, items, validation, params) do
page
|> parse(params)
|> slice(items, validation)
end
@doc """
Adds output items to page struct.
"""
@spec put_output(__MODULE__.t(), list) :: __MODULE__.t()
def put_output(page, nil), do: %{page | output: %Output{items: [], total: 0}}
def put_output(page, items) do
%{page | output: %Output{items: items, total: length(items)}}
end
@doc """
Filters the output items.
"""
@spec filter_output(__MODULE__.t()) :: __MODULE__.t()
def filter_output(%{output: %{items: items}} = page) do
filtered =
(build_output_filters(page) ++ build_output_all_filters(page))
|> Enum.reduce(items, &Enum.filter(&2, &1))
%{page | output: %Output{items: filtered, total: length(filtered)}}
end
@spec build_output_filters(__MODULE__.t()) :: list((any -> boolean))
defp build_output_filters(%{input: %{filter: filter}} = page) do
%{prefs: prefs, validation: validation} = page
%{schema: schema, base_filter: base_filter, fields: fields} = validation
# starting with keyword-list of user input filter
# like [name: "foo", created: "today"]
(filter ++ base_filter)
# replace each field-name key with Pageantry.Field definition struct
|> Enum.map(fn {field_name, value} -> {fields[field_name], value} end)
# filter out fields that are not eligible (eg filter=false)
|> Enum.filter(fn {field, _} -> field && field.filter end)
# add type to tuple
|> Enum.map(fn {field, value} ->
type = resolve_field_filter_type(field.field, schema, field.relation, field.filter)
{field, value, type}
end)
# cast each field value to type
|> Enum.map(fn {field, value, type} ->
{field, Cast.cast_to_type(type, value, prefs), type}
end)
# filter out fields with values that could not be casted
|> Enum.filter(fn {_, value, _} -> value != nil end)
# build filter function for each field
|> Enum.map(&filterer_for_field/1)
end
@spec build_output_all_filters(__MODULE__.t()) :: list((any -> boolean))
defp build_output_all_filters(%{input: %{filter: filter}} = page) do
build_output_all_filters(page, filter[:ALL])
end
@spec build_output_all_filters(__MODULE__.t(), any) :: list(fun)
defp build_output_all_filters(_page, nil), do: []
defp build_output_all_filters(_page, ""), do: []
defp build_output_all_filters(%{prefs: prefs, validation: validation}, value) do
%{schema: schema, fields: fields} = validation
# build list of all fields elligible for "all" filter
# each set to the same "all" value
filters =
fields
|> Enum.filter(fn {_, field} -> field.all end)
|> Enum.map(fn {_, field} -> {field, value} end)
# add type to tuple
|> Enum.map(fn {field, value} ->
type = resolve_field_filter_type(field.field, schema, field.relation, field.filter)
{field, value, type}
end)
# cast each field value to type
|> Enum.map(fn {field, value, type} ->
{field, Cast.cast_to_type(type, value, prefs), type}
end)
# filter out fields with values that could not be casted
|> Enum.filter(fn {_, value, _} -> value != nil end)
# build filter function for each field
|> Enum.map(&filterer_for_field/1)
if filters != [] do
[fn item -> Enum.any?(filters, & &1.(item)) end]
else
[]
end
end
@spec resolve_field_filter_type(String.t(), Schema.t(), FieldRelation.t(), atom) :: atom
defp resolve_field_filter_type(_name, _schema, _relation, :like), do: :string
defp resolve_field_filter_type(_name, _schema, _relation, :boolean), do: :boolean
defp resolve_field_filter_type(name, schema, relation, _filter) do
resolve_field_type(name, schema, relation)
end
@spec resolve_field_type(String.t(), Schema.t(), FieldRelation.t()) :: atom
defp resolve_field_type(name, schema, nil) do
schema.__schema__(:type, name)
end
defp resolve_field_type(name, schema, %{
relation: relation,
association: association,
schema: nil
}) do
%{related: related} = schema.__schema__(:association, association)
resolve_field_type(name, related, relation)
end
defp resolve_field_type(name, _schema, %{relation: relation, schema: schema}) do
resolve_field_type(name, schema, relation)
end
@spec filterer_for_field({Field.t(), any, atom}) :: (any -> boolean)
defp filterer_for_field({field, %{first: lo, last: hi}, type}) do
mapper = mapper_for_field(field.field, field.relation)
comparator = comparator_for_field_type(type)
fn item ->
item_value = mapper.(item)
# item_value >= lo && item_value <= hi
comparator.(item_value, lo) != :lt && comparator.(item_value, hi) != :gt
end
end
defp filterer_for_field({%{filter: :boolean} = field, _value, _type}) do
mapper = mapper_for_field(field.field, field.relation)
fn item -> mapper.(item) |> Cast.cast_to_boolean(nil) end
end
defp filterer_for_field({%{filter: :like} = field, value, _type}) do
mapper = mapper_for_field(field.field, field.relation)
case Regex.run(~r/^"(.*)"$/, value) do
[_, exact] -> fn item -> mapper.(item) == exact end
_ -> fn item -> mapper.(item) |> string_icontains?(value) end
end
end
defp filterer_for_field({field, value, {:array, _}}) do
mapper = mapper_for_field(field.field, field.relation)
fn item -> value in mapper.(item) end
end
defp filterer_for_field({field, value, _type}) do
mapper = mapper_for_field(field.field, field.relation)
fn item -> mapper.(item) == value end
end
@spec string_icontains?(String.t(), String.t()) :: boolean
defp string_icontains?(nil, _), do: false
defp string_icontains?(_, nil), do: false
defp string_icontains?(a, b), do: String.contains?(String.downcase(a), String.downcase(b))
@spec mapper_for_field(atom, FieldRelation.t()) :: fun
defp mapper_for_field(name, nil) do
&(&1 && Map.get(&1, name))
end
defp mapper_for_field(name, %{relation: relation, association: association}) do
fun = mapper_for_field(name, relation)
&(&1 && Map.get(&1, association) |> fun.())
end
@spec comparator_for_field_type(atom) :: (any, any -> :eq | :gt | :lt)
defp comparator_for_field_type(:utc_datetime), do: &datetime_compare/2
defp comparator_for_field_type(_type), do: &native_compare/2
@spec datetime_compare(DateTime.t(), DateTime.t()) :: :eq | :gt | :lt
defp datetime_compare(nil, nil), do: :eq
defp datetime_compare(nil, _), do: :lt
defp datetime_compare(_, nil), do: :gt
defp datetime_compare(a, b), do: DateTime.compare(a, b)
@spec native_compare(any, any) :: :eq | :gt | :lt
defp native_compare(a, b) when a < b, do: :lt
defp native_compare(a, b) when a > b, do: :gt
defp native_compare(_, _), do: :eq
@doc """
Sorts the output items.
"""
@spec sort_output(__MODULE__.t()) :: __MODULE__.t()
def sort_output(page) do
case build_output_sorter(page) do
nil -> page
sorter -> put_in(page.output.items, Enum.sort(page.output.items, sorter))
end
end
@spec build_output_sorter(__MODULE__.t()) :: (any, any -> boolean)
defp build_output_sorter(%{input: %{sort: sort}, validation: validation}) do
%{schema: schema, base_sort: base_sort, fields: fields} = validation
# starting with keyword-list like [asc: :name, desc: :id]
(sort ++ base_sort)
# look up each field in validation.fields keyword-list
|> Enum.map(fn {order, field_name} -> {order, fields[field_name]} end)
# filter out fields not in validation.fields keyword-list, or that are sort=false
|> Enum.filter(fn {_, field} -> field && field.sort end)
# build comparator function for each field
|> Enum.map(&comparator_for_field(&1, schema))
# build single sorter function from list of comparator fns
|> sorter_for_comparators()
end
@spec sorter_for_comparators(list((any, any -> :eq | :gt | :lt))) :: (any, any -> boolean)
defp sorter_for_comparators([]), do: nil
defp sorter_for_comparators(comparators) do
# returns true if a <= b, false if a > b
fn a, b ->
# runs each comparator in order until it finds a not :eq result
Enum.reduce_while(comparators, :eq, fn comparator, _acc ->
# simpler to keep this all in one function
# credo:disable-for-next-line Credo.Check.Refactor.Nesting
case comparator.(a, b) do
:eq -> {:cont, :eq}
x -> {:halt, x}
end
end) != :gt
end
end
@spec comparator_for_field({:asc | :desc, Field.t()}, Schema.t()) :: (any, any -> boolean)
defp comparator_for_field({order, %{field: name, relation: relation}}, schema) do
mapper = mapper_for_field(name, relation)
type = resolve_field_type(name, schema, relation)
comparator = comparator_for_field_type(type)
if order != :desc do
fn a, b -> comparator.(mapper.(a), mapper.(b)) end
else
fn a, b -> comparator.(mapper.(b), mapper.(a)) end
end
end
@doc """
Applies offset and max length to the output items.
"""
@spec slice_output(__MODULE__.t()) :: __MODULE__.t()
def slice_output(%{input: %{off: off, max: max}} = page) do
put_in(page.output.items, Enum.slice(page.output.items, off, max))
end
end