defmodule AshPagify do @moduledoc "README.md" |> File.read!() |> String.split("") |> Enum.fetch!(1) alias Ash.Page.Offset alias AshPagify.Error.Query.InvalidDirectionsError alias AshPagify.FilterForm alias AshPagify.Meta alias AshPagify.Misc alias AshPagify.Validation require Ash.Expr require Ash.Query require Logger @default_opts [ default_limit: 25, max_limit: 100, scopes: %{}, full_text_search: [], reset_on_filter?: true, replace_invalid_params?: false ] def default_opts, do: @default_opts @default_opts_keys Enum.map(@default_opts, fn {k, _} -> k end) def default_opts_keys, do: @default_opts_keys @internal_opts [ :__compiled_scopes, :__compiled_default_scopes, :for, :full_text_search ] defstruct limit: nil, offset: nil, scopes: nil, filter_form: nil, filters: nil, order_by: nil, search: nil @typedoc """ These options can be passed to most functions or configured via the application environment. ## Options Default ash_pagify options in addition to the ones provided by the `Ash.read/2` function. These options are used to configure the pagination behavior. - `:default_limit` - The default number of records to return. Defaults to 25. Can be overridden by the resource's `default_limit` function. - `:max_limit` - The maximum number of records that can be returned. Defaults to 100. - `:scopes` - A map of predefined filters to apply to the query. Each map entry itself is a group (list) of `t:AshPagify.scope/0` entries. - `:full_text_search` - A list of options for full-text search. See `t:AshPagify.Tsearch.tsearch_option/0`. - `:reset_on_filter?` - If set to `true`, the offset will be reset to 0 when a filter is applied. Defaults to `true`. - `:replace_invalid_params?` - If set to `true`, invalid parameters will be replaced with the default value. If set to `false`, invalid parameters will result in an error. Defaults to `false`. ## Look-up order Options are looked up in the following order: 1. Function arguments (highest priority) 2. Resource-level options (set in the resource module) 3. Global options in the application environment (set in config files) 4. Library defaults (lowest priority) """ @type option :: {default_limit :: non_neg_integer()} | {max_limit :: non_neg_integer()} | {scopes :: map()} | {full_text_search :: list(AshPagify.Tsearch.tsearch_option())} | {reset_on_filter? :: boolean()} | {replace_invalid_params? :: boolean()} @typedoc """ A scope is a predefined filter that is merged with the user-provided filters. Scope definitions live in the resource provided `ash_pagify_options scopes` function or in the provided `t:AshPagify.option/0`. Contrary to user-provided filters, scope filters are not parsed as user input and are not validated as such. However, they are validated in the `AshPagify.validate_and_run/4` context. User-provided parameters are used to lookup the scope filter. If the scope filter is found, it is applied to the query. If the scope filter is not found, an error is raised. ## Fields - `:name` - The name of the filter for the scope. - `:filter` - The filter to apply to the query. - `:default?` - If set to `true`, the scope is applied by default. """ @type scope :: {name :: atom()} | {filter :: Ash.Filter.t()} | {default? :: boolean()} @typedoc """ Valid order_by types for the `t:AshPagify.t/0` struct. """ @type order_by :: [atom() | String.t() | {atom(), Ash.Sort.sort_order()} | [String.t()]] | nil @typedoc """ Represents the query parameters for full-text search, scoping, filtering, ordering and pagination. ### Fields - `limit`, `offset`: Used for offset-based pagination. - `scopes`: A map of user provided scopes to apply to the query. Scopes are internally translated to predefined filters and merged into the query enginge. - `filter_form`: A map of filters provided by `AshPhoenix.FilterForm` module. These filters are meant to be used in user interfaces. - `filters`: A map of manually provided filters to apply to the query. These filters must be provided in the map syntax and are meant to be used in business logic context (see `Ash.Filter` for examples). - `order_by`: A list of fields to order by (see `Ash.Sort.parse_input/3` for all available orders). - `search`: A string to search for in the full-text search column. """ @type t :: %__MODULE__{ limit: pos_integer() | nil, offset: non_neg_integer() | nil, scopes: map() | nil, filter_form: map() | nil, filters: map() | nil, order_by: order_by(), search: String.t() | nil } @doc """ Adds clauses for full-text search, scoping, filtering, ordering and pagination to an `t:Ash.Query.t/0` or `t:Ash.Resource.t/0` from the given `t:AshPagify.t/0` parameters and `t:Keyword.t/0` options. The keyword list `opts` is used to pass additional options to the query engine. It shoud conform to the list of valid options at `Ash.read/2`. Furthermore the `t:AshPagify.option/0` library options are supported. We take the keyword list `opts` and return a keyword list callback according to `Ash.read/2` but with the __:query__ keyword also within the list. - `AshPagify.search` is used to apply full-text search to the query. - `Paigfy.scopes` are used to apply predefined filters to the query. - `AshPagify.filter_form` is used to apply filters generated by the `AshPhoenix.FilterForm` module. - `AshPagify.filters` and `AshPagify.order_by` are used to filter and order the query. - `AshPagify.limit` and `AshPagify.offset` are used to paginate the query. The user input parameters are represented by the `t:AshPagify.t/0` type. Any `nil` values will be ignored. ## Examples iex> alias AshPagify.Factory.Post iex> ash_pagify = %AshPagify{limit: 10, offset: 20, filters: %{name: "foo"}, order_by: ["name"]} iex> [page, {:query, query}] = parse(Post, ash_pagify) iex> page {:page, [count: true, offset: 20, limit: 10]} iex> query #Ash.Query, sort: [{"name", :asc}]> Or to disable counting: iex> alias AshPagify.Factory.Post iex> ash_pagify = %AshPagify{limit: 10, offset: 20, filters: %{name: "foo"}, order_by: ["name"]} iex> [page, {:query, query}] = parse(Post, ash_pagify, page: [count: false]) iex> page {:page, [count: false, offset: 20, limit: 10]} iex> query #Ash.Query, sort: [{"name", :asc}]> Sorting only: iex> alias AshPagify.Factory.Post iex> ash_pagify = %AshPagify{order_by: ["name"]} iex> [page, {:query, query}] = parse(Post, ash_pagify) iex> page {:page, [count: true, offset: 0, limit: 15]} iex> query #Ash.Query Filtering only: iex> alias AshPagify.Factory.Post iex> ash_pagify = %AshPagify{filters: %{name: "foo"}} iex> [page, {:query, query}] = parse(Post, ash_pagify) iex> page {:page, [count: true, offset: 0, limit: 15]} iex> query #Ash.Query> Pagination only: iex> alias AshPagify.Factory.Post iex> ash_pagify = %AshPagify{limit: 10, offset: 20} iex> [page, {:query, query}] = parse(Post, ash_pagify) iex> page {:page, [count: true, offset: 20, limit: 10]} iex> query #Ash.Query Scoping only: iex> alias AshPagify.Factory.Post iex> ash_pagify = %AshPagify{scopes: %{role: :admin}} iex> [page, {:query, query}] = parse(Post, ash_pagify) iex> page {:page, [count: true, offset: 0, limit: 15]} iex> query #Ash.Query> This function does _not_ validate or apply default parameters to the given AshPagify struct. Be sure to validate any user-generated parameters with `validate/2` or `validate!/2` before passing them to this function. Doing so will automatically parse user provided input into the correct format for the query engine. """ @spec parse(Ash.Query.t() | Ash.Resource.t(), AshPagify.t(), Keyword.t()) :: Keyword.t() def parse(query_or_resource, ash_pagify, opts \\ []) # sobelow_skip ["SQL.Query"] def parse(%Ash.Query{} = q, %AshPagify{} = ash_pagify, opts) do opts = Keyword.put(opts, :query, query(q, ash_pagify, opts)) paginate(q, ash_pagify, opts) end def parse(r, %AshPagify{} = ash_pagify, opts) when is_atom(r) and r != nil do parse(Ash.Query.new(r), ash_pagify, opts) end @doc """ Returns an `t:Ash.Page.Offset.t/0` struct from the given `t:Ash.Query.t/0` or `t:Ash.Resource.t/0` with the given `t:AshPagify.t/0` parameters and `t:Keyword.t/0` options. The `opts` keyword list is used to pass additional options to the query engine. It should conform to the list of valid options at `Ash.read/2`. - `AshPagify.search` is used to apply full-text search to the query. - `Paigfy.scopes` are used to apply predefined filters to the query. - `AshPagify.filter_form` is used to apply filters generated by the `AshPhoenix.FilterForm` module. - `AshPagify.filters` and `AshPagify.order_by` are used to filter and order the query. - `AshPagify.limit` and `AshPagify.offset` are used to paginate the query. The user input parameters are represented by the `t:AshPagify.t/0` type. Any `nil` values will be ignored. If the `:action` option is set (to perform a custom read action), the fourth argument `args` will be passed to the action as arguments. ## Examples iex> alias AshPagify.Factory.Post iex> %Ash.Page.Offset{results: r} = AshPagify.all(Post, %AshPagify{filters: %{name: "inexistent"}}) iex> r [] Or with an initial query: iex> alias AshPagify.Factory.Post iex> q = Ash.Query.filter_input(Post, %{name: "inexistent"}) iex> %Ash.Page.Offset{results: r} = AshPagify.all(q, %AshPagify{}) iex> r [] Or with a custom read action: iex> alias AshPagify.Factory.Post iex> alias AshPagify.Factory.Comment iex> Comment.read!() |> Enum.count() 9 iex> ash_pagify = %AshPagify{limit: 1, filters: %{name: "Post 1"}} iex> %Ash.Page.Offset{results: posts} = AshPagify.all(Post, ash_pagify) iex> post = hd(posts) iex> %Ash.Page.Offset{count: count} = AshPagify.all(Comment, %AshPagify{}, [action: :by_post], post.id) iex> count 2 Or with scopes: iex> alias AshPagify.Factory.Post iex> ash_pagify = %AshPagify{scopes: %{role: :admin}} iex> %Ash.Page.Offset{count: count} = AshPagify.all(Post, ash_pagify) iex> count 1 This function does _not_ validate or apply default parameters to the given AshPagify struct. Be sure to validate any user-generated parameters with `validate/2` or `validate!/2` before passing them to this function. Doing so will automatically parse user provided input into the correct format for the query engine. """ @spec all(Ash.Query.t() | Ash.Resource.t(), AshPagify.t(), Keyword.t(), any()) :: Offset.t() def all(query_or_resource, ash_pagify, opts \\ [], args \\ nil) def all(%Ash.Query{resource: r} = q, %AshPagify{} = ash_pagify, opts, args) do opts = parse(q, ash_pagify, opts) opts = remove_ash_pagify_opts(opts) case Keyword.get(opts, :action) do nil -> r.read!(opts) action -> {:ok, page} = apply(r, action, [args, opts]) page end end def all(r, %AshPagify{} = ash_pagify, opts, args) when is_atom(r) and r != nil do all(Ash.Query.new(r), ash_pagify, opts, args) end defp remove_ash_pagify_opts(opts) do Enum.filter(opts, fn {k, _} -> !Enum.member?(@default_opts_keys, k) and !Enum.member?(@internal_opts, k) end) end @doc """ Returns the total count of entries matching the full-text search, filters, filter_form, and scopes conditions in the given `t:Ash.Query.t/0` or `t:Ash.Resource.t/0` with the given `t:AshPagify.t/0` parameters and `t:Keyword.t/0` options. The pagination and ordering options are disregarded. iex> alias AshPagify.Factory.Post iex> AshPagify.count(Post, %AshPagify{}) 3 You can override the default query by passing the `:count_query` option. This doesn't make a lot of sense when you use `count/3` directly, but allows you to optimize the count query when you use one of the `run/4`, `validate_and_run/4` and `validate_and_run!/4` functions. query = some expensive query count_query = Ash.Query.new(Post) AshPagify.count(Post, %AshPagify{}, count_query: count_query) The full-text search and various filter parameters of the given AshPagify are applied to the custom count query. If for some reason you already have the count, you can pass it as the `:count` option. count(query, %AshPagify{}, count: 42, for: Post) If you pass both the `:count` and the `:count_query` options, the `:count` option will take precedence. This function does _not_ validate or apply default parameters to the given AshPagify struct. Be sure to validate any user-generated parameters with `validate/2` or `validate!/2` before passing them to this function. Doing so will automatically parse user provided input into the correct format for the query engine. Or you can use `AshPagify.validate_and_run/4` or `AshPagify.validate_and_run!/4` instead of this function. """ @spec count(Ash.Query.t() | Ash.Resource.t(), AshPagify.t(), Keyword.t()) :: non_neg_integer() def count(query_or_resource, ash_pagify, opts \\ []) # sobelow_skip ["SQL.Query"] def count(%Ash.Query{resource: r} = q, %AshPagify{} = ash_pagify, opts) do if count = opts[:count] do count else q = if count_query = opts[:count_query] do count_query else query(q, ash_pagify, Keyword.put_new(opts, :for, r)) end opts = Keyword.delete(opts, :count_query) opts = Keyword.delete(opts, :count) Ash.count!(q, opts) end end def count(r, %AshPagify{} = ash_pagify, opts) when is_atom(r) and r != nil do count(Ash.Query.new(r), ash_pagify, opts) end @doc """ Applies the given `t:AshPagify.t/0` to the given `t:Ash.Query.t/0` or `t:Ash.Resource.t/0`, retrieves the data and the `t:AshPagify.Meta.t/0` data. This function does _not_ validate or apply default parameters to the given AshPagify struct. Be sure to validate any user-generated parameters with `validate/2` or `validate!/2` before passing them to this function. Doing so will automatically parse user provided input into the correct format for the query engine. Or you can use `AshPagify.validate_and_run/4` or `AshPagify.validate_and_run!/4` instead of this function. ## Examples iex> alias AshPagify.Factory.Post iex> opts = [page: [count: false]] iex> ash_pagify = AshPagify.validate!(Post, %{filters: %{name: "inexistent"}}, opts) iex> {data, meta} = AshPagify.run(Post, ash_pagify, opts) iex> data == [] true iex> match?(%AshPagify.Meta{}, meta) true See the documentation for `AshPagify.validate_and_run/4` for supported options. """ @spec run(Ash.Query.t() | Ash.Resource.t(), AshPagify.t(), Keyword.t(), any()) :: {[Ash.Resource.record()], Meta.t()} def run(query_or_resource, ash_pagify, opts \\ [], args \\ nil) def run(%Ash.Query{} = q, %AshPagify{} = ash_pagify, opts, args) do page = all(q, ash_pagify, opts, args) meta = meta(page, ash_pagify, opts) {page.results, meta} end def run(r, %AshPagify{} = ash_pagify, opts, args) when is_atom(r) and r != nil do run(Ash.Query.new(r), ash_pagify, opts, args) end @doc """ Validates the given ash_pagify parameters and retrieves the data and meta data on success. ## Examples iex> alias AshPagify.Factory.Post iex> {:ok, {[%Post{},%Post{},%Post{}], %AshPagify.Meta{}}} = ...> AshPagify.validate_and_run(Post, %AshPagify{}) iex> {:error, %AshPagify.Meta{} = meta} = ...> AshPagify.validate_and_run(Post, %{limit: -1}) iex> AshPagify.Error.clear_stacktrace(meta.errors) [ limit: [ %Ash.Error.Query.InvalidLimit{limit: -1} ] ] Or with a custom read action: iex> alias AshPagify.Factory.Post iex> alias AshPagify.Factory.Comment iex> Comment.read!() |> Enum.count() 9 iex> ash_pagify = %AshPagify{limit: 1, filters: %{name: "Post 1"}} iex> {:ok, {posts, _meta}} = AshPagify.validate_and_run(Post, ash_pagify) iex> post = hd(posts) iex> {:ok, {_comments, meta}} = AshPagify.validate_and_run(Comment, %AshPagify{}, [action: :by_post], post.id) iex> meta.total_count 2 Or with scopes: iex> alias AshPagify.Factory.Post iex> {:ok, {[%Post{}], %AshPagify.Meta{}}} = AshPagify.validate_and_run(Post, %AshPagify{scopes: %{role: :user}}) ## Options The keyword list `opts` is used to pass additional options to the query engine. It shoud conform to the list of valid options at `Ash.read/2`. Furthermore the `t:AshPagify.option/0` library options are supported. """ @spec validate_and_run( Ash.Query.t() | Ash.Resource.t(), map() | AshPagify.t(), Keyword.t(), any() ) :: {:ok, {[Ash.Resource.record()], Meta.t()}} | {:error, Meta.t()} def validate_and_run(query_or_resource, map_or_ash_pagify, opts \\ [], args \\ nil) do opts = query_or_resource |> Misc.maybe_put_compiled_scopes(opts) |> Keyword.put_new(:for, get_resource(query_or_resource)) with {:ok, ash_pagify} <- validate(query_or_resource, map_or_ash_pagify, opts) do {:ok, run(query_or_resource, ash_pagify, opts, args)} end end @doc """ Same as `AshPagify.validate_and_run/4`, but raises on error. """ @spec validate_and_run!( Ash.Query.t() | Ash.Resource.t(), map() | AshPagify.t(), Keyword.t(), any() ) :: {[Ash.Resource.record()], Meta.t()} def validate_and_run!(query_or_resource, map_or_ash_pagify, opts \\ [], args \\ nil) do opts = query_or_resource |> Misc.maybe_put_compiled_scopes(opts) |> Keyword.put_new(:for, get_resource(query_or_resource)) ash_pagify = validate!(query_or_resource, map_or_ash_pagify, opts) run(query_or_resource, ash_pagify, opts, args) end @doc """ Returns meta information for the given query and ash_pagify that can be used for building the pagination links. ## Examples iex> alias AshPagify.Factory.Post iex> ash_pagify = %AshPagify{limit: 2, offset: 1, order_by: [name: :asc, comments_count: :desc_nils_last]} iex> page = AshPagify.all(Post, ash_pagify) iex> AshPagify.meta(page, ash_pagify) %AshPagify.Meta{ current_limit: 2, current_offset: 1, current_page: 2, default_scopes: %{status: :all}, has_next_page?: false, has_previous_page?: true, next_offset: nil, opts: [], ash_pagify: %AshPagify{filters: nil, limit: 2, offset: 1, order_by: [name: :asc, comments_count: :desc_nils_last]}, previous_offset: 0, resource: Post, total_count: 3, total_pages: 2 } This function does _not_ validate or apply default parameters to the given AshPagify struct. Be sure to validate any user-generated parameters with `validate/2` or `validate!/2` before passing them to this function. Doing so will automatically parse user provided input into the correct format for the query engine. """ @spec meta(Offset.t(), AshPagify.t(), Keyword.t()) :: Meta.t() def meta(%Offset{} = page, %AshPagify{} = ash_pagify, opts \\ []) do total_count = page.count page_size = page.limit total_pages = get_total_pages(total_count, page_size) current_offset = get_current_offset(page.offset) current_page = get_current_page(page, total_pages) current_search = ash_pagify.search {has_previous_page?, previous_offset} = get_previous(current_offset, page_size) {has_next_page?, next_offset} = get_next(current_offset, page_size, total_count) resource = get_resource(page) default_scopes = get_default_scopes(resource, opts) %Meta{ current_limit: page_size, current_offset: current_offset, current_page: current_page, current_search: current_search, default_scopes: default_scopes, has_next_page?: has_next_page?, has_previous_page?: has_previous_page?, next_offset: next_offset, opts: remove_ash_pagify_opts(opts), ash_pagify: ash_pagify, previous_offset: previous_offset, resource: resource, total_count: total_count, total_pages: total_pages } end defp get_resource(%Offset{rerun: {original_query, _}}), do: original_query.resource defp get_resource(%Ash.Query{resource: r}), do: r defp get_resource(resource) when is_atom(resource) and resource != nil, do: resource defp get_previous(offset, limit) do has_previous? = offset > 0 previous_offset = if has_previous?, do: max(0, offset - limit), else: 0 {has_previous?, previous_offset} end defp get_next(_, nil = _page_size, _) do {false, nil} end defp get_next(current_offset, page_size, total_count) when current_offset + page_size >= total_count do {false, nil} end defp get_next(current_offset, page_size, _) do {true, current_offset + page_size} end defp get_total_pages(0, _), do: 0 defp get_total_pages(nil, _), do: 0 defp get_total_pages(_, nil), do: 1 defp get_total_pages(total_count, limit), do: ceil(total_count / limit) defp get_current_offset(nil), do: 0 defp get_current_offset(offset), do: offset defp get_current_page(%Offset{offset: nil}, _), do: 1 defp get_current_page(%Offset{offset: offset, limit: limit}, total_pages) do page = ceil(offset / limit) + 1 min(page, total_pages) end defp get_default_scopes(resource, opts) do opts = Misc.maybe_put_compiled_scopes(resource, opts) Keyword.get(opts, :__compiled_default_scopes) end @doc """ Transforms the given `order_by` parameter into a list of strings (user input domain). """ @spec concat_sort(order_by(), [String.t()]) :: [String.t()] def concat_sort(list, acc \\ []) def concat_sort(nil, _), do: nil def concat_sort([], []), do: nil def concat_sort([], acc), do: Enum.reverse(acc) def concat_sort(order_by, acc) when is_binary(order_by), do: concat_sort([order_by], acc) def concat_sort(order_by, acc) when is_atom(order_by), do: concat_sort([order_by], acc) def concat_sort(order_by, acc) when is_tuple(order_by), do: concat_sort([order_by], acc) def concat_sort([field | rest], acc) do case field do {field, order} -> concat_sort(rest, ["#{order_to_prefix(order)}#{Atom.to_string(field)}" | acc]) field when is_binary(field) -> concat_sort(rest, [field | acc]) field when is_atom(field) -> concat_sort(rest, [Atom.to_string(field) | acc]) end end defp order_to_prefix(:asc_nils_first), do: "++" defp order_to_prefix(:desc), do: "-" defp order_to_prefix(:desc_nils_last), do: "--" defp order_to_prefix(_), do: "" @doc """ Transforms the given field with order prefix into an `t:Ash.Sort.sort_order/t`. ## Examples iex> AshPagify.prefix_to_order("name") :asc iex> AshPagify.prefix_to_order("-name") :desc iex> AshPagify.prefix_to_order("++name") :asc_nils_first iex> AshPagify.prefix_to_order("--name") :desc_nils_last iex> AshPagify.prefix_to_order("+name") :asc """ @spec prefix_to_order(String.t()) :: Ash.Sort.sort_order() def prefix_to_order("++" <> field) when is_binary(field), do: :asc_nils_first def prefix_to_order("--" <> field) when is_binary(field), do: :desc_nils_last def prefix_to_order("+" <> field) when is_binary(field), do: :asc def prefix_to_order("-" <> field) when is_binary(field), do: :desc def prefix_to_order(_), do: :asc # Query @doc """ Adds clauses for full-text search, scoping, filtering and ordering to an `t:Ash.Query.t/0` from the given `t:AshPagify.t/0` parameter. This function does _not_ validate or apply default parameters to the given AshPagify struct. Be sure to validate any user-generated parameters with `validate/2` or `validate!/2` before passing them to this function. Doing so will automatically parse user provided input into the correct format for the query engine. ## Examples iex> alias AshPagify.Factory.Post iex> q = Ash.Query.new(Post) iex> ash_pagify = %AshPagify{filters: %{name: "John"}, order_by: ["name"]} iex> query(q, ash_pagify) #Ash.Query, sort: [{"name", :asc}]> """ @spec query(Ash.Query.t(), AshPagify.t(), Keyword.t()) :: Ash.Query.t() def query(%Ash.Query{} = q, %AshPagify{} = ash_pagify, opts \\ []) do q |> search(ash_pagify, opts) |> scope(ash_pagify, opts) |> filter_form(ash_pagify) |> filter(ash_pagify) |> order_by(ash_pagify) end ## Search @doc """ Applies the `search` parameter of a `t:AshPagify.t/0` to an `t:Ash.Query.t/0`. Used by `AshPagify.query/2`. AshPagify allows you to perform full-text searches on resources. It uses the built-in [PostgreSQL full-text search functionality](https://www.postgresql.org/docs/current/textsearch.html). Have a look at the `t:AshPagify.Tsearch.tsearch_option/0` type for a list of available options. If search is provided and there is no order_by, the query will be sorted by the rank of the search. This function does _not_ validate or apply default parameters to the given AshPagify struct. Be sure to validate any user-generated parameters with `validate/2` or `validate!/2` before passing them to this function. Doing so will automatically parse user provided input into the correct format for the query engine. """ @spec search(Ash.Query.t(), AshPagify.t(), Keyword.t()) :: Ash.Query.t() def search(q, ash_pagify, opts \\ []) def search(%Ash.Query{} = q, %AshPagify{search: nil}, _opts), do: q def search(%Ash.Query{} = q, %AshPagify{search: ""}, _opts), do: q def search(%Ash.Query{} = q, %AshPagify{search: search} = ash_pagify, opts) do tsquery_str = AshPagify.Tsearch.tsquery(search, opts) tsquery_expr = Ash.Expr.expr(tsquery(search: ^tsquery_str)) tsvector_expr = AshPagify.Tsearch.tsvector(opts) q |> Ash.Query.filter(full_text_search(tsvector: ^tsvector_expr, tsquery: ^tsquery_expr)) |> maybe_put_ts_rank(ash_pagify, tsvector_expr, tsquery_expr) end defp maybe_put_ts_rank(%Ash.Query{} = q, %AshPagify{order_by: order_by}, tsvector_expr, tsquery_expr) when is_nil(order_by) or order_by == [] do Ash.Query.sort(q, full_text_search_rank: {%{tsvector: tsvector_expr, tsquery: tsquery_expr}, :desc} ) end defp maybe_put_ts_rank(%Ash.Query{} = q, _, _, _), do: q ## Scope @doc """ Applies the `scopes` parameter of a `t:AshPagify.t/0` to an `t:Ash.Query.t/0`. Used by `AshPagify.query/2`. At this stage we assume that the scopes are already compiled and validated. Further, default scopes are loaded into the AshPagify struct. For a completed list of filter operators, see `Ash.Filter`. This function does _not_ validate or apply default parameters to the given AshPagify struct. Be sure to validate any user-generated parameters with `validate/2` or `validate!/2` before passing them to this function. Doing so will automatically parse user provided input into the correct format for the query engine. ## Examples iex> alias AshPagify.Factory.Post iex> q = Ash.Query.new(Post) iex> ash_pagify = %AshPagify{scopes: %{status: :active}} iex> scope(q, ash_pagify) #Ash.Query> """ @spec scope(Ash.Query.t(), AshPagify.t(), Keyword.t()) :: Ash.Query.t() def scope(q, ash_pagify, opts \\ []) def scope(%Ash.Query{} = q, %AshPagify{scopes: nil}, _), do: q def scope(%Ash.Query{resource: resource} = query, %AshPagify{scopes: scopes}, opts) when is_map(scopes) do opts = Misc.maybe_put_compiled_scopes(resource, opts) compiled_scopes = Keyword.get(opts, :__compiled_scopes) Enum.reduce(scopes, query, fn {group, name}, acc -> apply_scope(acc, compiled_scopes, group, name) end) end defp apply_scope(query, compiled_scopes, group, name) do group_scopes = get_group_scopes(compiled_scopes, group) scope = find_scope(group_scopes, group, name) if scope.filter == nil do query else Ash.Query.filter(query, ^scope.filter) end end defp get_group_scopes(compiled_scopes, group) do case Map.get(compiled_scopes, group) do nil -> raise ArgumentError, "Group `#{group}` not found" group_scopes -> group_scopes end end defp find_scope(group_scopes, group, name) do Enum.find(group_scopes, fn scope -> scope.name == name end) || raise ArgumentError, "Scope `#{name}` not found in group `#{group}`" end ## Filter Form @doc """ Applies the `filter_form` parameter of a `t:AshPagify.t/0` to an `t:Ash.Query.t/0`. Used by `AshPagify.query/2`. See `AshPhoenix.FilterForm` for more information. This function does _not_ validate or apply default parameters to the given AshPagify struct. Be sure to validate any user-generated parameters with `validate/2` or `validate!/2` before passing them to this function. Doing so will automatically parse user provided input into the correct format for the query engine. ## Examples iex> alias AshPagify.Factory.Post iex> q = Ash.Query.new(Post) iex> ash_pagify = %AshPagify{filter_form: %{"field" => "name", "operator" => "eq", "value" => "Post 1"}} iex> filter_form(q, ash_pagify) #Ash.Query> """ @spec filter_form(Ash.Query.t(), AshPagify.t()) :: Ash.Query.t() def filter_form(q, ash_pagify) def filter_form(%Ash.Query{} = q, %AshPagify{filter_form: nil}), do: q def filter_form(%Ash.Query{} = q, %AshPagify{filter_form: %{} = filter_form}) when filter_form == %{}, do: q def filter_form(%Ash.Query{resource: r} = q, %AshPagify{filter_form: filter_form}) do filter_map = filter_form_to_filter_map(r, filter_form) Ash.Query.filter(q, ^filter_map) end def filter_form(%Ash.Query{} = q, _), do: q ## Filter @doc """ Applies the `filter` parameter of a `t:AshPagify.t/0` to an `t:Ash.Query.t/0`. Used by `AshPagify.query/2`. See `Ash.Query.filter/2` for more information. For a completed list of filter operators, see `Ash.Filter`. This function does _not_ validate or apply default parameters to the given AshPagify struct. Be sure to validate any user-generated parameters with `validate/2` or `validate!/2` before passing them to this function. Doing so will automatically parse user provided input into the correct format for the query engine. ## Examples iex> alias AshPagify.Factory.Post iex> q = Ash.Query.new(Post) iex> ash_pagify = %AshPagify{filters: %{name: "foo"}} iex> filter(q, ash_pagify) #Ash.Query> Or multiple filters: iex> alias AshPagify.Factory.Post iex> q = Ash.Query.new(Post) iex> ash_pagify = %AshPagify{filters: %{name: "foo", id: "1"}} iex> filter(q, ash_pagify) #Ash.Query> Or by relation: iex> alias AshPagify.Factory.Post iex> q = Ash.Query.new(Post) iex> ash_pagify = %AshPagify{filters: %{comments: %{body: "foo"}}} iex> filter(q, ash_pagify) #Ash.Query> """ @spec filter(Ash.Query.t(), AshPagify.t()) :: Ash.Query.t() def filter(q, ash_pagify) def filter(%Ash.Query{} = q, %AshPagify{filters: nil}), do: q def filter(%Ash.Query{} = q, %AshPagify{filters: []}), do: q def filter(%Ash.Query{} = q, %AshPagify{filters: filters}) do Ash.Query.filter(q, ^filters) end def filter(%Ash.Query{} = q, _), do: q ## Ordering @doc """ Applies the `order_by` parameter of a `t:AshPagify.t/0` to an `t:Ash.Query.t/0`. Used by `AshPagify.query/2`. See `Ash.Query.sort/3` for more information. This function does _not_ validate or apply default parameters to the given AshPagify struct. Be sure to validate any user-generated parameters with `validate/2` or `validate!/2` before passing them to this function. Doing so will automatically parse user provided input into the correct format for the query engine. ## Examples iex> alias AshPagify.Factory.Post iex> q = Ash.Query.new(Post) iex> ash_pagify = %AshPagify{order_by: ["name"]} iex> order_by(q, ash_pagify) #Ash.Query Or descending order nulls last: iex> alias AshPagify.Factory.Post iex> q = Ash.Query.new(Post) iex> ash_pagify = %AshPagify{order_by: [name: :desc_nils_last]} iex> order_by(q, ash_pagify) #Ash.Query Or multiple fields: iex> alias AshPagify.Factory.Post iex> q = Ash.Query.new(Post) iex> ash_pagify = %AshPagify{order_by: ["name", "id"]} iex> order_by(q, ash_pagify) #Ash.Query Or by calculation: iex> alias AshPagify.Factory.Post iex> q = Ash.Query.new(Post) iex> ash_pagify = %AshPagify{order_by: ["comments_count"]} iex> order_by(q, ash_pagify) #Ash.Query """ @spec order_by(Ash.Query.t(), AshPagify.t()) :: Ash.Query.t() def order_by(q, ash_pagify) def order_by(%Ash.Query{} = q, %AshPagify{order_by: nil}), do: q def order_by(%Ash.Query{} = q, %AshPagify{order_by: []}), do: q def order_by(%Ash.Query{} = q, %AshPagify{order_by: order_by}) do Ash.Query.sort(q, order_by) end def order_by(%Ash.Query{} = q, _), do: q # Pagination @doc """ Adds clauses for pagination to the resulting keyword list from the given `t:AshPagify.t/0` parameter. The `count` parameter is set to `true` by default. To disable counting the total number of records, set `page: [:count, false]` in the opts keyword list. If the `limit` or `offset` fields are `nil`, the default limit and offset values will be used. If the resource itself provides a default limit, it will be used instead of the default limit provided by AshPagify. ## Examples iex> alias AshPagify.Factory.Post iex> ash_pagify = %AshPagify{limit: 10, offset: 20} iex> paginate(Post, ash_pagify) [page: [count: true, offset: 20, limit: 10]] Or disable counting: iex> alias AshPagify.Factory.Post iex> ash_pagify = %AshPagify{limit: 10, offset: 20} iex> paginate(Post, ash_pagify, page: [count: false]) [page: [count: false, offset: 20, limit: 10]] Or without the offset parameter: iex> alias AshPagify.Factory.Post iex> ash_pagify = %AshPagify{limit: 8} iex> paginate(Post, ash_pagify) [page: [count: true, offset: 0, limit: 8]] Or without the limit parameter. The default limit from Post will be used: iex> alias AshPagify.Factory.Post iex> ash_pagify = %AshPagify{offset: 5} iex> paginate(Post, ash_pagify) [page: [count: true, offset: 5, limit: 15]] Or without the limit parameter. The default limit from AshPagify will be used if no default limit is provided by the resource: iex> alias AshPagify.Factory.Comment iex> ash_pagify = %AshPagify{offset: 5} iex> paginate(Comment, ash_pagify) [page: [count: true, offset: 5, limit: 25]] This function does _not_ validate or apply default parameters to the given AshPagify struct. Be sure to validate any user-generated parameters with `validate/2` or `validate!/2` before passing them to this function. Doing so will automatically parse user provided input into the correct format for the query engine. """ @spec paginate(Ash.Query.t() | Ash.Resource.t(), AshPagify.t(), Keyword.t()) :: Keyword.t() def paginate(query_or_resource, ash_pagify, opts \\ []) def paginate(%Ash.Query{} = q, %AshPagify{} = ash_pagify, opts) do page_opts = Keyword.get(opts, :page) page = q |> put_default_limit(ash_pagify) |> page(page_opts) Keyword.put(opts, :page, page) end def paginate(r, ash_pagify, opts) when is_atom(r) and r != nil do paginate(Ash.Query.new(r), ash_pagify, opts) end @spec put_default_limit(Ash.Query.t(), AshPagify.t()) :: AshPagify.t() defp put_default_limit(q, ash_pagify) defp put_default_limit(%Ash.Query{resource: r}, %AshPagify{limit: nil} = ash_pagify) when is_atom(r) and r != nil do %{ash_pagify | limit: Misc.get_option(:default_limit, for: r)} end defp put_default_limit(_, %AshPagify{limit: nil} = ash_pagify) do %{ash_pagify | limit: Misc.get_option(:default_limit)} end defp put_default_limit(_, ash_pagify), do: ash_pagify @doc """ Returns a keyword list with the `limit`, `offset` and `count` parameters from the given `t:AshPagify.t/0` parameter. The `count` parameter is set to `true` by default. To disable counting the total number of records, set `count: false` in the optional page keyword list. ## Examples iex> ash_pagify = %AshPagify{limit: 10, offset: 20} iex> page(ash_pagify) [count: true, offset: 20, limit: 10] Or disable counting: iex> ash_pagify = %AshPagify{limit: 10, offset: 20} iex> page(ash_pagify, count: false) [count: false, offset: 20, limit: 10] """ @spec page(AshPagify.t(), Keyword.t()) :: Keyword.t() def page(ash_pagify, page \\ [count: true]) def page(%AshPagify{limit: limit, offset: offset}, count: count) when is_integer(limit) and limit >= 1 and (is_integer(offset) and offset >= 0) do [] |> Keyword.put(:limit, limit) |> Keyword.put(:offset, offset) |> Keyword.put(:count, count) end def page(%AshPagify{limit: limit, offset: offset}, count: count) when is_integer(limit) and limit >= 1 and is_nil(offset) do [] |> Keyword.put(:limit, limit) |> Keyword.put(:offset, 0) |> Keyword.put(:count, count) end def page(%AshPagify{limit: limit, offset: offset}, count: count) when is_nil(limit) and (is_integer(offset) and offset >= 0) do [] |> Keyword.put(:limit, Misc.get_option(:default_limit)) |> Keyword.put(:offset, offset) |> Keyword.put(:count, count) end def page(%AshPagify{limit: limit, offset: offset}, _) when is_integer(limit) and limit >= 1 and (is_integer(offset) and offset >= 0) do [] |> Keyword.put(:limit, limit) |> Keyword.put(:offset, offset) |> Keyword.put(:count, true) end def page(%AshPagify{limit: limit, offset: offset}, _) when is_integer(limit) and limit >= 1 and is_nil(offset) do [] |> Keyword.put(:limit, limit) |> Keyword.put(:offset, 0) |> Keyword.put(:count, true) end def page(%AshPagify{limit: limit, offset: offset}, _) when is_nil(limit) and (is_integer(offset) and offset >= 0) do [] |> Keyword.put(:limit, Misc.get_option(:default_limit)) |> Keyword.put(:offset, offset) |> Keyword.put(:count, true) end # Validation @doc """ Validates a `t:AshPagify.t/0`. ## Examples iex> alias AshPagify.Factory.Post iex> params = %{limit: 10, offset: 20, other_param: "foo"} iex> AshPagify.validate(Post, params) {:ok, %AshPagify{limit: 10, offset: 20, scopes: %{status: :all}}} iex> ash_pagify = %AshPagify{offset: -1} iex> {:error, %AshPagify.Meta{} = meta} = AshPagify.validate(Post, ash_pagify) iex> AshPagify.Error.clear_stacktrace(meta.errors) [ offset: [ %Ash.Error.Query.InvalidOffset{offset: -1} ] ] The function is aware of the `Ash.Resource` type passed either as query or as resource. Thus the function is able to validate that only allowed fields are used for scoping, ordering and filtering. The function will also apply the default_limit and scoping if the resource provides one. """ @spec validate(Ash.Query.t() | Ash.Resource.t(), map() | AshPagify.t(), Keyword.t()) :: {:ok, AshPagify.t()} | {:error, Meta.t()} def validate(query_or_resource, map_or_ash_pagify, opts \\ []) def validate(query_or_resource, %AshPagify{} = ash_pagify, opts) do map = ash_pagify_struct_to_map(ash_pagify) validate(query_or_resource, map, opts) end def validate(query_or_resource, %{} = params, opts) do result = Validation.validate_params(query_or_resource, params, opts) case result do {:ok, _} -> result {:error, errors, maybe_valid_params} -> Logger.debug("Invalid AshPagify: #{inspect(errors)}") {:error, Meta.with_errors(maybe_valid_params, errors, opts)} end end defp ash_pagify_struct_to_map(%AshPagify{} = ash_pagify) do ash_pagify |> Map.from_struct() |> Enum.reject(fn {_, value} -> is_nil(value) end) |> Map.new() end @doc """ Same as `AshPagify.validate/2`, but raises a `AshPagify.Error.Query.InvalidParamsError` if the parameters are invalid. """ @spec validate!(Ash.Query.t() | Ash.Resource.t(), map() | AshPagify.t(), Keyword.t()) :: AshPagify.t() def validate!(query_or_resource, map_or_ash_pagify, opts \\ []) do case validate(query_or_resource, map_or_ash_pagify, opts) do {:ok, ash_pagify} -> ash_pagify {:error, %Meta{errors: errors}} -> raise AshPagify.Error.Query.InvalidParamsError, errors: errors, params: map_or_ash_pagify end end @doc """ Validates the given query or resource and ash_pagify parameters and returns a validated query. ## Examples iex> alias AshPagify.Factory.Post iex> ash_pagify = %AshPagify{limit: 10, offset: 20, order_by: ["name"], filters: %{name: "foo"}} iex> validated_query(Post, ash_pagify) #Ash.Query, sort: [name: :asc]> """ @spec validated_query(Ash.Query.t() | Ash.Resource.t(), map() | AshPagify.t(), Keyword.t()) :: Ash.Query.t() def validated_query(query_or_resource, map_or_ash_pagify, opts \\ []) # sobelow_skip ["SQL.Query"] def validated_query(%Ash.Query{} = q, map_or_ash_pagify, opts) do ash_pagify = validate!(q, map_or_ash_pagify, opts) query(q, ash_pagify, opts) end def validated_query(r, map_or_ash_pagify, opts) when is_atom(r) and r != nil do validated_query(Ash.Query.new(r), map_or_ash_pagify, opts) end @doc """ Sets the tsvector value in the full_text_search clause of the `Keyword.t` opts parameter. If the full_text_search clause does not exist, it will be created. If the tsvector value already exists, it will be updated. ## Examples iex> set_tsvector("bar", [full_text_search: [tsvector: "foo"]]) [full_text_search: [tsvector: "bar"]] iex> set_tsvector("bar") [full_text_search: [tsvector: "bar"]] iex> set_tsvector("foo", [full_text_search: [tsvector: "foo"]]) [full_text_search: [tsvector: "foo"]] """ def set_tsvector(tsvector, opts \\ []) do Keyword.update( opts, :full_text_search, [tsvector: tsvector], fn full_text_search -> Keyword.put(full_text_search, :tsvector, tsvector) end ) end @doc """ Sets the limit value of a `AshPagify` struct. iex> set_limit(%AshPagify{limit: 10, offset: 10}, 20) %AshPagify{limit: 20, offset: 10} iex> set_limit(%AshPagify{limit: 10, offset: 10}, "20") %AshPagify{limit: 20, offset: 10} The limit will not be allowed to go below 1. iex> set_limit(%AshPagify{}, -5) %AshPagify{limit: 25} If the limit is higher than the max_limit option, the limit will be set to the max_limit. iex> set_limit(%AshPagify{}, 102) %AshPagify{limit: 100} """ @spec set_limit(AshPagify.t(), pos_integer(), Keyword.t()) :: AshPagify.t() def set_limit(ash_pagify, limit, opts \\ []) def set_limit(%AshPagify{} = ash_pagify, limit, opts) when is_integer(limit) and limit >= 1 do if limit <= Misc.get_option(:max_limit, opts) do %{ash_pagify | limit: limit} else %{ash_pagify | limit: Misc.get_option(:max_limit, opts)} end end def set_limit(%AshPagify{} = ash_pagify, limit, opts) when is_binary(limit) do set_limit(ash_pagify, String.to_integer(limit), opts) end def set_limit(%AshPagify{} = ash_pagify, _, opts) do %{ash_pagify | limit: Misc.get_option(:default_limit, opts)} end @doc """ Sets the offset value of a `AshPagify` struct. iex> set_offset(%AshPagify{limit: 10, offset: 10}, 20) %AshPagify{offset: 20, limit: 10} iex> set_offset(%AshPagify{limit: 10, offset: 10}, "20") %AshPagify{offset: 20, limit: 10} The offset will not be allowed to go below 0. iex> set_offset(%AshPagify{}, -5) %AshPagify{offset: 0} """ @spec set_offset(AshPagify.t(), non_neg_integer | binary) :: AshPagify.t() def set_offset(%AshPagify{} = ash_pagify, offset) when is_integer(offset) do %{ ash_pagify | offset: max(offset, 0) } end def set_offset(%AshPagify{} = ash_pagify, offset) when is_binary(offset) do set_offset(ash_pagify, String.to_integer(offset)) end @doc """ Sets the offset of a AshPagify struct to the page depending on the limit. ## Examples iex> to_previous_offset(%AshPagify{offset: 20, limit: 10}) %AshPagify{offset: 10, limit: 10} iex> to_previous_offset(%AshPagify{offset: 5, limit: 10}) %AshPagify{offset: 0, limit: 10} iex> to_previous_offset(%AshPagify{offset: 0, limit: 10}) %AshPagify{offset: 0, limit: 10} iex> to_previous_offset(%AshPagify{offset: -2, limit: 10}) %AshPagify{offset: 0, limit: 10} """ @spec to_previous_offset(AshPagify.t()) :: AshPagify.t() def to_previous_offset(%AshPagify{offset: 0} = ash_pagify), do: ash_pagify def to_previous_offset(%AshPagify{offset: offset, limit: limit} = ash_pagify) when is_integer(limit) and is_integer(offset), do: %{ash_pagify | offset: max(0, offset - limit)} @doc """ Sets the offset of a AshPagify struct to the next page depending on the limit. If the total count is given as the second argument, the offset will not be increased if the last page has already been reached. You can get the total count from the `AshPagify.Meta` struct. If the AshPagify has an offset beyond the total count, the offset will be set to the last page. ## Examples iex> to_next_offset(%AshPagify{offset: 10, limit: 5}) %AshPagify{offset: 15, limit: 5} iex> to_next_offset(%AshPagify{offset: 15, limit: 5}, 21) %AshPagify{offset: 20, limit: 5} iex> to_next_offset(%AshPagify{offset: 15, limit: 5}, 20) %AshPagify{offset: 15, limit: 5} iex> to_next_offset(%AshPagify{offset: 28, limit: 5}, 22) %AshPagify{offset: 20, limit: 5} iex> to_next_offset(%AshPagify{offset: -5, limit: 20}) %AshPagify{offset: 0, limit: 20} """ @spec to_next_offset(AshPagify.t(), non_neg_integer | nil) :: AshPagify.t() def to_next_offset(ash_pagify, total_count \\ nil) def to_next_offset(%AshPagify{limit: limit, offset: offset} = ash_pagify, _) when is_integer(limit) and is_integer(offset) and offset < 0, do: %{ash_pagify | offset: 0} def to_next_offset(%AshPagify{limit: limit, offset: offset} = ash_pagify, nil) when is_integer(limit) and is_integer(offset), do: %{ash_pagify | offset: offset + limit} def to_next_offset(%AshPagify{limit: limit, offset: offset} = ash_pagify, total_count) when is_integer(limit) and is_integer(offset) and is_integer(total_count) and offset >= total_count do %{ash_pagify | offset: (ceil(total_count / limit) - 1) * limit} end def to_next_offset(%AshPagify{limit: limit, offset: offset} = ash_pagify, total_count) when is_integer(limit) and is_integer(offset) and is_integer(total_count) do case offset + limit do new_offset when new_offset >= total_count -> ash_pagify new_offset -> %{ash_pagify | offset: new_offset} end end @doc """ Sets the search of a AshPagify struct. If the reset option is set to false, the offset will not be reset to 0. ## Examples iex> set_search(%AshPagify{offset: 10}, "term") %AshPagify{search: "term"} iex> set_search(%AshPagify{offset: 10, search: "old"}, "new") %AshPagify{search: "new"} iex> set_search(%AshPagify{offset: 10, search: "old"}, nil) %AshPagify{search: nil} Or without reset offset: iex> set_search(%AshPagify{offset: 10}, "term", reset_on_filter?: false) %AshPagify{search: "term", offset: 10} """ @spec set_search(AshPagify.t(), String.t() | nil, Keyword.t()) :: AshPagify.t() def set_search(ash_pagify, search, opts \\ []) def set_search(%AshPagify{} = ash_pagify, search, opts) do ash_pagify = %{ash_pagify | search: search} reset_on_filter = Misc.get_option(:reset_on_filter?, opts, true) if reset_on_filter do %{ash_pagify | offset: nil} else ash_pagify end end @doc """ Sets the scope of a AshPagify struct. If the scope already exists, it will be replaced with the new value. If the scope does not exist, it will be added to the scopes map. If the reset option is set to false, the offset will not be reset to 0. ## Examples iex> set_scope(%AshPagify{offset: 10, scopes: %{status: :active}}, %{status: :inactive}) %AshPagify{scopes: %{status: :inactive}} iex> set_scope(%AshPagify{offset: 10, scopes: %{status: :active}}, %{status: :active}) %AshPagify{scopes: %{status: :active}} Or add a new scope: iex> set_scope(%AshPagify{offset: 10, scopes: %{role: :admin}}, %{status: :active}) %AshPagify{scopes: %{status: :active, role: :admin}} iex> set_scope(%AshPagify{}, %{role: :admin}) %AshPagify{scopes: %{role: :admin}} Or without reset offset: iex> set_scope(%AshPagify{offset: 10}, %{status: :active}, reset_on_filter?: false) %AshPagify{scopes: %{status: :active}, offset: 10} """ @spec set_scope(AshPagify.t(), map(), Keyword.t()) :: AshPagify.t() def set_scope(ash_pagify, scope, opts \\ []) def set_scope(%AshPagify{} = ash_pagify, scope, opts) do scopes = ash_pagify.scopes || %{} ash_pagify = %{ash_pagify | scopes: Map.merge(scopes, scope)} reset_on_filter = Misc.get_option(:reset_on_filter?, opts, true) if reset_on_filter do %{ash_pagify | offset: nil} else ash_pagify end end @doc """ Helper function to check if a scope is active in a AshPagify struct. ## Examples iex> active_scope?(%AshPagify{scopes: %{status: :active}}, %{status: :active}) true iex> active_scope?(%AshPagify{scopes: %{status: :active}}, %{status: :inactive}) false iex> active_scope?(%AshPagify{scopes: %{status: :active}}, %{role: :admin}) false iex> active_scope?(%AshPagify{}, %{role: :admin}) false """ @spec active_scope?(AshPagify.t(), map()) :: boolean def active_scope?(%AshPagify{scopes: nil}, _), do: false def active_scope?(%AshPagify{scopes: scopes}, scope) do group = scope |> Map.keys() |> hd() name = scope |> Map.values() |> hd() case Map.get(scopes, group) do nil -> false active -> active == name end end @doc """ Removes all filters from a AshPagify struct. ## Example iex> reset_filters(%AshPagify{filters: %{ ...> name: "foo", ...> }}) %AshPagify{filters: %{}} """ @spec reset_filters(AshPagify.t()) :: AshPagify.t() def reset_filters(%AshPagify{} = ash_pagify), do: %{ash_pagify | filters: %{}} @doc """ Removes all filter_form from a AshPagify struct. ## Example iex> reset_filter_form(%AshPagify{ ...> filter_form: %{"field" => "name", "operator" => "eq", "value" => "Post 1"} ...> }) %AshPagify{filter_form: %{}} """ @spec reset_filter_form(AshPagify.t()) :: AshPagify.t() def reset_filter_form(%AshPagify{} = ash_pagify), do: %{ash_pagify | filter_form: %{}} @doc """ Updates the filter form of a AshPagify.Meta struct. If the filter already exists, it will be replaced with the new value. If the filter does not exist, it will be added to the filter form map. If the reset option is set to false, the offset will not be reset to 0. ## Examples iex> set_filter_form(%AshPagify.Meta{}, %{"field" => "name", "operator" => "eq", "value" => "Post 2"}) %AshPagify.Meta{ash_pagify: %AshPagify{filter_form: %{"field" => "name", "operator" => "eq", "value" => "Post 2"}}} iex> set_filter_form(%AshPagify.Meta{ash_pagify: %AshPagify{filter_form: %{"field" => "name", "operator" => "eq", "value" => "Post 1"}}}, %{"field" => "name", "operator" => "eq", "value" => "Post 2"}) %AshPagify.Meta{ash_pagify: %AshPagify{filter_form: %{"field" => "name", "operator" => "eq", "value" => "Post 2"}}} iex> set_filter_form(%AshPagify.Meta{ash_pagify: %AshPagify{filter_form: %{"field" => "name", "operator" => "eq", "value" => "Post 1"}}}, %{"negated" => false, "operator" => "and"}) %AshPagify.Meta{ash_pagify: %AshPagify{filter_form: nil}} """ @spec set_filter_form(Meta.t(), map(), Keyword.t()) :: Meta.t() def set_filter_form(meta, filter_form, opts \\ []) def set_filter_form(%Meta{ash_pagify: ash_pagify} = meta, filter_form, opts) when filter_form == %{"negated" => false, "operator" => "and"} do ash_pagify = maybe_reset_offset(%{ash_pagify | filter_form: nil}, opts) %{meta | ash_pagify: ash_pagify} end def set_filter_form(%Meta{ash_pagify: ash_pagify} = meta, filter_form, opts) do ash_pagify = maybe_reset_offset(%{ash_pagify | filter_form: filter_form}, opts) %{meta | ash_pagify: ash_pagify} end defp maybe_reset_offset(%AshPagify{} = ash_pagify, opts) do reset_on_filter = Misc.get_option(:reset_on_filter?, opts, true) if reset_on_filter do %{ash_pagify | offset: nil} else ash_pagify end end @doc """ Merges the given filters with the filters of a AshPagify struct. If the filter already exists, it will be replaced with the new value. If the filter does not exist, it will be added to the filters map. In order to merge the filters, the filters are first prepared by calling `prepare_filters/1`. This function will ensure that the filters are in the correct format for merging (e.g. keys are strings). If the filters are in the correct format, the filters are merged using `Misc.deep_merge/2`. After merging, the filters are cleaned up by removing empty lists. ## Examples iex> merge_filters(%AshPagify{filters: %{name: "foo"}}, %{name: "bar"}) %AshPagify{filters: %{"and" => [%{"name" => "bar"}]}} iex> merge_filters(%AshPagify{filters: %{name: "foo"}}, %{age: 10}) %AshPagify{filters: %{"and" => [%{"name" => "foo"}, %{"age" => 10}]}} iex> merge_filters(%AshPagify{filters: %{"or" => [%{name: "foo"}]}}, %{age: 10}) %AshPagify{filters: %{"or" => [%{"name" => "foo"}], "and" => [%{"age" => 10}]}} iex> merge_filters(%AshPagify{filters: %{"or" => [%{name: "foo"}]}}, %{"or" => [%{age: 10}]}) %AshPagify{filters: %{"or" => [%{"name" => "foo"}, %{"age" => 10}]}} """ @spec merge_filters(AshPagify.t(), map() | true) :: AshPagify.t() def merge_filters(ash_pagify, nil), do: ash_pagify def merge_filters(ash_pagify, true), do: ash_pagify def merge_filters(%AshPagify{} = ash_pagify, filters) do source = prepare_filters(ash_pagify.filters || %{}) target = prepare_filters(filters || %{}) merged = source |> Misc.deep_merge(target) |> Enum.reject(fn {_, value} -> value == [] end) |> Map.new() %{ash_pagify | filters: merged} end defp prepare_filters(%{} = filters) do keys = Map.keys(filters) cond do keys == [] -> %{"and" => []} Enum.member?(keys, "and") or Enum.member?(keys, "or") -> Misc.stringify_keys(filters) true -> %{"and" => [Misc.stringify_keys(filters)]} end end defp prepare_filters(filters), do: Misc.stringify_keys(filters) @doc """ Transforms the `filter_form` parameter of a `t:AshPagify.t/0` into a filter map. Used by `AshPagify.filter_form/2`. See `AshPhoenix.FilterForm` for more information. This function does _not_ validate or apply default parameters to the given AshPagify struct. Be sure to validate any user-generated parameters with `validate/2` or `validate!/2` before passing them to this function. Doing so will automatically parse user provided input into the correct format for the query engine. ## Examples iex> alias AshPagify.Factory.Post iex> ash_pagify = %AshPagify{filter_form: %{"field" => "name", "operator" => "eq", "value" => "Post 1"}} iex> filter_form_to_filter_map(Post, ash_pagify.filter_form) %{"and" => [%{"name" => %{"eq" => "Post 1"}}]} """ @spec filter_form_to_filter_map(Ash.Resource.t(), map() | nil) :: map() def filter_form_to_filter_map(_resource, nil), do: %{} def filter_form_to_filter_map(resource, filter_form) do resource |> FilterForm.new(params: filter_form) |> FilterForm.to_filter_map() |> elem(1) end @doc """ Takes the AshPagify.scopes and AshPagify.form_filter and compiles them into a map of filters. The filters are merged with the base filters of the AshPagify struct. At this stage we assume that the filters, filter_form, and scopes have been validated and are valid. > #### Full-text search {: .info} > Per default we do store the full-text search term along with the user provided full-text search options in the compiled filters map. If you do not need to include the full-text search setting in the compiled filters map, you can set the `include_full_text_search?` option to `false`. The full-text search setting is stored under the key `"__full_text_search"` in the resulting filters map. This can be handy if you want to store the current filter state including the full-text search setting and retrieve it later. See `AshPagify.query_for_filters_map/2` for an example. Precedence: - scopes (will overwrite filter_form and filters) - filter_form (will overwrite filters) - filters ## Examples iex> alias AshPagify.Factory.Post iex> query_to_filters_map(Post, %AshPagify{scopes: [{:role, :admin}]}) %AshPagify{filters: %{"and" => [%{"author" => "John"}]}, scopes: [role: :admin]} iex> query_to_filters_map(Post, %AshPagify{filters: %{name: "foo"}}) %AshPagify{filters: %{"and" => [%{"name" => "foo"}]}} iex> query_to_filters_map( ...> Post, ...> %AshPagify{ ...> filters: %{author: "Author 1"}, ...> filter_form: %{"field" => "name", "operator" => "eq", "value" => "Post 1"}, ...> scopes: [{:role, :admin}] ...> } ...> ) %AshPagify{ scopes: [role: :admin], filters: %{"and" => [%{"author" => "John"}, %{"name" => %{"eq" => "Post 1"}}]}, filter_form: %{"field" => "name", "operator" => "eq", "value" => "Post 1"} } # Or with a full-text search term iex> query_to_filters_map( ...> Post, ...> %AshPagify{ ...> search: "search term", ...> filters: %{author: "Author 1"}, ...> filter_form: %{"field" => "name", "operator" => "eq", "value" => "Post 1"}, ...> scopes: [{:role, :admin}] ...> } ...> ) %AshPagify{ scopes: [role: :admin], filters: %{ "and" => [ %{"author" => "John"}, %{"name" => %{"eq" => "Post 1"}} ], "__full_text_search" => %{ "search" => "search term" } }, filter_form: %{"field" => "name", "operator" => "eq", "value" => "Post 1"}, search: "search term" } """ @spec query_to_filters_map(Ash.Query.t() | Ash.Resource.t(), AshPagify.t(), Keyword.t()) :: AshPagify.t() def query_to_filters_map(query_or_resource, ash_pagify, opts \\ []) def query_to_filters_map(%Ash.Query{resource: resource}, %AshPagify{} = ash_pagify, opts) do filter_form = filter_form_to_filter_map(resource, ash_pagify.filter_form) scopes_filters = load_scopes_filters(resource, ash_pagify.scopes, opts) ash_pagify |> merge_filters(filter_form) |> merge_filters(scopes_filters) |> maybe_store_full_text_search(resource, opts) end def query_to_filters_map(r, %AshPagify{} = ash_pagify, opts) when is_atom(r) and r != nil do query_to_filters_map(Ash.Query.new(r), ash_pagify, opts) end defp load_scopes_filters(_resource, nil, _opts), do: %{} defp load_scopes_filters(resource, scopes, opts) do opts = Misc.maybe_put_compiled_scopes(resource, opts) compiled_scopes = Keyword.get(opts, :__compiled_scopes) Enum.reduce(scopes, %{}, fn {group, name}, acc -> get_scope_filter(acc, compiled_scopes, group, name) end) end defp get_scope_filter(filters, compiled_scopes, group, name) do group_scopes = get_group_scopes(compiled_scopes, group) scope = find_scope(group_scopes, group, name) if scope.filter == nil do filters else Map.merge(filters, scope.filter) end end defp maybe_store_full_text_search(%AshPagify{search: nil} = ash_pagify, _resource, _opts), do: ash_pagify defp maybe_store_full_text_search(%AshPagify{search: ""} = ash_pagify, _resource, _opts), do: ash_pagify defp maybe_store_full_text_search(%AshPagify{search: search} = ash_pagify, resource, opts) do if search != nil and Keyword.get(opts, :include_full_text_search?, true) do store_full_text_search(ash_pagify, resource, opts) else ash_pagify end end defp store_full_text_search(%AshPagify{search: search} = ash_pagify, resource, opts) do ash_pagify |> Validation.validate_search(Keyword.put_new(opts, :for, resource)) |> maybe_raise_on_invalid_search(search, opts) end defp maybe_raise_on_invalid_search(ash_pagify, search, opts) do if Map.get(ash_pagify, :errors) == nil do user_provided_full_text_search_opts = opts |> Keyword.get(:full_text_search, []) |> Keyword.put(:search, search) |> maybe_put_tsvector(get_in(opts, [:full_text_search, :tsvector])) |> Enum.filter(fn {key, _} -> key in AshPagify.Tsearch.option_keys() end) |> Map.new() |> Misc.stringify_keys(keys: AshPagify.Tsearch.option_keys(), depth: 1) %{ ash_pagify | filters: Map.put( ash_pagify.filters || %{}, "__full_text_search", user_provided_full_text_search_opts ) } else if Keyword.get(opts, :raise_on_invalid_search?, true) do ash_pagify |> Map.get(:errors, []) |> Keyword.get(:search, []) |> hd() |> raise() else ash_pagify end end end defp maybe_put_tsvector(opts, nil), do: opts defp maybe_put_tsvector(opts, tsvector) when is_binary(tsvector), do: Keyword.put(opts, :tsvector, tsvector) defp maybe_put_tsvector(opts, tsvector) when is_atom(tsvector), do: Keyword.put(opts, :tsvector, Atom.to_string(tsvector)) defp maybe_put_tsvector(opts, _), do: opts @doc """ Creates an `Ash.Query` from a filter map. Ideally, the filter map was previously compiled with `AshPagify.query_to_filters_map/2`. Optionally, you can pass the `include_full_text_search?: false` option to disable the full-text search term inclusion in the query. If the full-text search term is included in the compiled filters map, it will be removed from the filters map before the query is created. Further, the full-text search is validated before beeing applied to the query. If the full-text search is invalid and the `raise_on_invalid_search?` option is not set to `false`, the function will raise an error. ## Examples iex> alias AshPagify.Factory.Post iex> filters_map = %{"and" => [%{"name" => "foo"}]} iex> query_for_filters_map(Post, filters_map) #Ash.Query> """ @spec query_for_filters_map(Ash.Query.t() | Ash.Resource.t(), map(), Keyword.t()) :: Ash.Query.t() def query_for_filters_map(query_or_resource, filters_map, opts \\ []) def query_for_filters_map(query_or_resource, %{} = filters_map, opts) do {filters_map, full_text_search} = extract_full_text_search(filters_map) query_or_resource |> Ash.Query.filter_input(filters_map) |> maybe_apply_full_text_search(full_text_search, opts) end @doc """ Extracts the full-text search setting from the filters map and returns a tuple of the filters map without the full-text search setting and the full-text search setting. The full-text search setting is stored under the key `"__full_text_search"` in the filters map (on in the `and` or `or` base of the filters_map). If the full-text search setting is not found, the function will return the filters map as is. """ @spec extract_full_text_search(map()) :: {map(), map() | nil} def extract_full_text_search(%{"__full_text_search" => full_text_search} = filters_map) do {Map.delete(filters_map, "__full_text_search"), full_text_search} end def extract_full_text_search(%{"and" => filters} = filters_map) do split_and_combine(filters_map, filters, "and") end def extract_full_text_search(%{"or" => filters} = filters_map) do split_and_combine(filters_map, filters, "or") end def extract_full_text_search(filters_map), do: {filters_map, nil} defp split_and_combine(filters_map, combinator_filters, combinator) do {full_text_search, combinator_filters} = Enum.split_with(combinator_filters, &Map.has_key?(&1, "__full_text_search")) filters_map = cond do combinator_filters == [] && full_text_search == [] -> filters_map combinator_filters == [] -> Map.delete(filters_map, combinator) true -> Map.put(filters_map, combinator, combinator_filters) end full_text_search = if full_text_search == [] do nil else full_text_search |> hd() |> Map.get("__full_text_search", nil) end {filters_map, full_text_search} end @spec maybe_apply_full_text_search(Ash.Query.t(), map(), Keyword.t()) :: Ash.Query.t() defp maybe_apply_full_text_search(%Ash.Query{} = query, nil, _opts), do: query defp maybe_apply_full_text_search(query, %{"search" => nil}, _opts), do: query defp maybe_apply_full_text_search(query, %{"search" => ""}, _opts), do: query defp maybe_apply_full_text_search(%Ash.Query{} = query, full_text_search, opts) do if Keyword.get(opts, :include_full_text_search?, true) do apply_full_text_search(query, full_text_search, opts) else query end end @spec apply_full_text_search(Ash.Query.t(), map(), Keyword.t()) :: Ash.Query.t() defp apply_full_text_search(%Ash.Query{resource: r} = query, %{"search" => search} = full_text_search, opts) do ash_pagify = %AshPagify{search: search} full_text_search = full_text_search |> Map.delete("search") |> Enum.map(fn {key, value} -> {String.to_existing_atom(key), value} end) |> Enum.filter(fn {key, _} -> key in AshPagify.Tsearch.option_keys() end) opts = opts |> Keyword.put(:full_text_search, full_text_search) |> Keyword.put_new(:for, r) ash_pagify |> Validation.validate_search(opts) |> maybe_raise_on_invalid_search_apply(query, opts) end defp maybe_raise_on_invalid_search_apply(ash_pagify, query, opts) do if Map.get(ash_pagify, :errors) == nil do search(query, ash_pagify, opts) else if Keyword.get(opts, :raise_on_invalid_search?, true) do ash_pagify |> Map.get(:errors, []) |> Keyword.get(:search, []) |> hd() |> raise() else query end end end @doc """ Returns the current order direction for the given field. ## Examples iex> ash_pagify = %AshPagify{order_by: [name: :desc, age: :asc]} iex> current_order(ash_pagify, :name) :desc iex> current_order(ash_pagify, :age) :asc iex> current_order(ash_pagify, :species) nil If the field is not an atom, the function will return `nil`. iex> ash_pagify = %AshPagify{order_by: [name: :desc]} iex> current_order(ash_pagify, "name") nil If `AshPagify.order_by` is nil, the function will return `nil`. iex> current_order(%AshPagify{}, :name) nil """ @spec current_order(AshPagify.t(), atom) :: Ash.Sort.sort_order() | nil def current_order(%AshPagify{order_by: nil}, _field), do: nil def current_order(%AshPagify{order_by: order_by}, field) when is_atom(field) do case Enum.find(order_by, &(elem(&1, 0) == field)) do {_, order} -> order nil -> nil end end def current_order(_, _), do: nil @doc """ Resets the order of a AshPagify struct. ## Example iex> reset_order(%AshPagify{order_by: [name: :asc]}) %AshPagify{order_by: nil} """ @spec reset_order(AshPagify.t()) :: AshPagify.t() def reset_order(%AshPagify{} = ash_pagify), do: %{ash_pagify | order_by: nil} @doc """ Updates the `order_by` value of a `AshPagify` struct. - If the field is not in the current `order_by` value, it will be prepended to the list. By default, the order direction for the field will be set to `:asc`. - If the field is already at the front of the `order_by` list, the order direction will be reversed. - If the field is already in the list, but not at the front, it will be moved to the front and the order direction will be set to `:asc` (or the custom asc direction supplied in the `:directions` option). - If the `:directions` option --a 2-element tuple-- is passed, the first and second elements will be used as custom sort declarations for ascending and descending, respectively. ## Examples iex> ash_pagify = push_order(%AshPagify{}, :name) iex> ash_pagify.order_by [name: :asc] iex> ash_pagify = push_order(ash_pagify, :age) iex> ash_pagify.order_by [age: :asc, name: :asc] iex> ash_pagify = push_order(ash_pagify, :age) iex> ash_pagify.order_by [age: :desc, name: :asc] iex> ash_pagify = push_order(ash_pagify, :species) iex> ash_pagify.order_by [species: :asc, age: :desc, name: :asc] iex> ash_pagify = push_order(ash_pagify, :age) iex> ash_pagify.order_by [age: :asc, species: :asc, name: :asc] By default, the function toggles between `:asc` and `:desc`. You can override this with the `:directions` option. iex> directions = {:asc_nils_first, :desc_nils_last} iex> ash_pagify = push_order(%AshPagify{}, :ttfb, directions: directions) iex> ash_pagify.order_by [ttfb: :asc_nils_first] iex> ash_pagify = push_order(ash_pagify, :ttfb, directions: directions) iex> ash_pagify.order_by [ttfb: :desc_nils_last] This also allows you to sort in descending order initially. iex> directions = {:desc, :asc} iex> ash_pagify = push_order(%AshPagify{}, :ttfb, directions: directions) iex> ash_pagify.order_by [ttfb: :desc] iex> ash_pagify = push_order(ash_pagify, :ttfb, directions: directions) iex> ash_pagify.order_by [ttfb: :asc] If a string is passed as the second argument, it will be converted to an atom using `String.to_existing_atom/1`. If the atom does not exist, the `AshPagify` struct will be returned unchanged. iex> ash_pagify = push_order(%AshPagify{}, "name") iex> ash_pagify.order_by [name: :asc] iex> ash_pagify = push_order(%AshPagify{}, "this_atom_does_not_exist") iex> ash_pagify.order_by nil If the `order_by` is either an atom or a binary, the function will return the coerced `order_by` value. iex> ash_pagify = push_order(%AshPagify{order_by: "author"}, :name) iex> ash_pagify.order_by [name: :asc, author: :asc] iex> ash_pagify = push_order(%AshPagify{order_by: :author}, "name") iex> ash_pagify.order_by [name: :asc, author: :asc] If the `:limit_order_by` option is passed, the `order_by` will be limited to the given number of fields. iex> ash_pagify = push_order(%AshPagify{order_by: [name: :asc, age: :asc]}, :species, limit_order_by: 1) iex> ash_pagify.order_by [species: :asc] """ @spec push_order(AshPagify.t(), atom() | String.t(), Keyword.t()) :: AshPagify.t() def push_order(ash_pagify, field, opts \\ []) def push_order(%AshPagify{order_by: order_by} = ash_pagify, field, opts) when is_atom(field) do order_by = coerce_order_by(order_by) previous_index = get_index(order_by, field) previous_direction = get_order_direction(order_by, previous_index) directions = Keyword.get(opts, :directions, nil) new_direction = new_order_direction(previous_index, previous_direction, directions) order_by = case previous_index do nil -> [{field, new_direction} | order_by] idx -> [{field, new_direction} | List.delete_at(order_by, idx)] end order_by = limit_order_by(order_by, opts) %AshPagify{ash_pagify | order_by: order_by} end def push_order(ash_pagify, field, opts) when is_binary(field) do push_order(ash_pagify, String.to_existing_atom(field), opts) rescue ArgumentError -> ash_pagify end defp limit_order_by(order_by, opts) do case Keyword.get(opts, :limit_order_by) do limit when is_integer(limit) and limit > 0 -> Enum.take(order_by, limit) _ -> order_by end end @doc """ Transforms the given `order_by` parameter into a list of tuples with the field and the default :asc direction. ## Examples iex> coerce_order_by(nil) [] iex> coerce_order_by([]) [] iex> coerce_order_by(:name) [name: :asc] iex> coerce_order_by("name") [name: :asc] iex> coerce_order_by({:name, :asc}) [name: :asc] iex> coerce_order_by([name: :asc, age: :desc]) [name: :asc, age: :desc] """ @spec coerce_order_by(order_by()) :: order_by() def coerce_order_by(nil), do: [] def coerce_order_by([]), do: [] def coerce_order_by(order_by) when is_atom(order_by), do: [{order_by, :asc}] def coerce_order_by(order_by) when is_binary(order_by), do: [{String.to_existing_atom(order_by), :asc}] def coerce_order_by(order_by) when is_tuple(order_by), do: [order_by] def coerce_order_by(order_by) when is_list(order_by) do Enum.map(order_by, fn {field, direction} when is_binary(field) -> {String.to_existing_atom(field), direction} {field, direction} -> {field, direction} field when is_binary(field) -> {String.to_existing_atom(field), :asc} field when is_atom(field) -> {field, :asc} end) end @doc """ Finds the current index of a field in the `order_by` list. Following rules are applied: - if the `order_by` is `nil`, `nil` is returned - if the `order_by` is an atom or a binary, `nil` is returned - if the `order_by` is a tuple, `nil` is returned - if the `order_by` is a list, the index of the field is returned """ @spec get_index(order_by(), atom()) :: non_neg_integer() | nil def get_index(order_by, field) def get_index(nil, _field), do: nil def get_index([], _field), do: nil def get_index(order_by, _field) when is_atom(order_by), do: nil def get_index(order_by, _field) when is_binary(order_by), do: nil def get_index(order_by, _field) when is_tuple(order_by), do: nil def get_index(order_by, field) when is_binary(field), do: get_index(order_by, String.to_existing_atom(field)) def get_index(order_by, field) do Enum.find_index(order_by, fn item -> case item do {f, _} -> f == field f when is_binary(f) -> String.to_existing_atom(f) == field f -> f == field end end) end @doc """ Returns the current order direction for the given index and `AshPagify.order_by`. Following rules are applied: - if the `order_by` is `nil`, `nil` is returned - if the `order_by` is an atom or a binary, `:asc` is returned - if the `order_by` is a tuple, the second element of the tuple is returned - if the index is out of bounds, `nil` is returned - if the `order_by` is a list, the direction of the element at the given index is returned """ @spec get_order_direction(order_by(), non_neg_integer() | nil) :: Ash.Sort.sort_order() | nil def get_order_direction(order_by, index) def get_order_direction(_, nil), do: :asc def get_order_direction(nil, _), do: nil def get_order_direction([], _), do: nil def get_order_direction(order_by, _) when is_atom(order_by), do: :asc def get_order_direction(order_by, _) when is_binary(order_by), do: :asc def get_order_direction(order_by, _) when is_tuple(order_by), do: Enum.at(order_by, 1) def get_order_direction(order_by, index) do case Enum.at(order_by, index, :asc) do {_, direction} -> direction _ -> :asc end end defguardp is_direction(value) when value in [ :asc, :asc_nils_first, :desc, :desc_nils_last ] defguardp is_asc_direction(value) when value in [ :asc, :asc_nils_first ] defguardp is_desc_direction(value) when value in [ :desc, :desc_nils_last ] defp new_order_direction(0, current_direction, nil), do: reverse_direction(current_direction) defp new_order_direction(0, current_direction, {_asc, desc}) when is_asc_direction(current_direction) and is_desc_direction(desc), do: desc defp new_order_direction(0, current_direction, {desc, _asc}) when is_asc_direction(current_direction) and is_desc_direction(desc), do: desc defp new_order_direction(0, current_direction, {asc, _desc}) when is_desc_direction(current_direction) and is_asc_direction(asc), do: asc defp new_order_direction(0, current_direction, {_desc, asc}) when is_desc_direction(current_direction) and is_asc_direction(asc), do: asc defp new_order_direction(0, _current_direction, directions) do raise InvalidDirectionsError, directions: directions end defp new_order_direction(_, _, nil), do: :asc defp new_order_direction(_, _, {asc, _desc}) when is_direction(asc), do: asc defp new_order_direction(_, _, directions) do raise InvalidDirectionsError, directions: directions end defp reverse_direction(:asc), do: :desc defp reverse_direction(:asc_nils_first), do: :desc_nils_last defp reverse_direction(:desc), do: :asc defp reverse_direction(:desc_nils_last), do: :asc_nils_first end