lib/limit_offset_paginator.ex

defmodule LimitOffsetPaginator do
  @moduledoc """
  LimitOffsetPaginator adds one function to your Repo - paginate.
  To use this function, you need to make use of the `use` macro in your Repo module.
  It needs to receive the module itself as a `repo` parameter.
  Together with that, you can add your default limit, offset, and field by which the list will be ordered by.
  There are also four extra options:
  `enable_custom_field`, when set to true, enables the user to change the order by field by passing a `field` variable into the params. It is false by default;
  `enable_custom_limit`, when set to true, enables the user to change the limit in the query by passing a `limit` variable into the params. It is true by default;
  `enable_custom_offset`, when set to true, enables the user to change the offset in the query by passing an `offset` variable into the params. It is true by default;
  `show_count_by_default`, when set to true, makes it so that you must pass `show_count: false` in order to not receive the total counts, for more info on `show_count` see the doc on `paginate` from your Repo.

  ## Example

      defmodule MyApp.Repo do
        use Ecto.Repo,
          otp_app: :me_app,
          adapter: Ecto.Adapters.Postgres

        use LimitOffsetPaginator,
          repo: MyApp.Repo
      end
  """
  @moduledoc since: "1.1.0"
  defmacro __using__(opts) do
    quote bind_quoted: [opts: opts] do
      import Ecto.Query
      @limit Keyword.get(opts, :limit, 10)
      @offset Keyword.get(opts, :offset, 0)
      @field Keyword.get(opts, :field, :id)
      @repo opts[:repo]

      @doc """
      Use this instead of Repo.all if you want to enable pagination.
      `params` is a map which can be (and frankly should be) passed directly from the controller.
      Parameter keys can be both strings and atoms, integer values can be strings and integers.
      What each parameter does:
      `paginate_clean` or `pc` - if set to true, the function will return a list without the map.
      `paginate` - if set to false, no pagination occurs.
      `field` - when `enable_custom_field` is turned on, this parameter will be used for order by.
      `limit` or `l` - when `enable_custom_limit` is turned on, it will be used instead of the default limit set by default values.
      `page` or `p` - what page needs to be fetched.
      `filter` or `f` - receives a string in this format: "path.to.key:filter_value:filter_type|path.t...". Automatically converts `filter_value` to a required field type from the schema. `filter_type` can be one of "in", "inc", "includes" if you need an `ilike`; "ex", "exc", "excludes" for `not ilike`; "eq", "equal" for `==`; "dif", "different" for `!=`; "eql", "equallist" for `in`; "difl", "differentlist" for `not in` (for this and eql use , to separate multiple values); "mo", "more" for `>`; "le", "less" for `<`; "moq", "moreequal" for `>=`; "leq", "lessequal" for `<=`. Multiple filters can be added by dividing the string with a "|".
      `sort` or `s` - receives a string in this format: "path.to.key:sort_direction|path.t...". Like `filter`, the function can find fields present in maps in a `select` statement or main schema if `select` is nil. `sort_direction` can be "asc", "ascending" or "desc", "descending". Can sort many fields by dividing the string with a "|".
      `offset` - when `enable_custom_offset` is turned on, it will be used instead of the default offset set by default values.
      `search_string` or `search` or `ss` - when this value is passed, string fields from the `from` schema or the `select` statement if it is present will be compared to the passed `search_string`. This directly alters the query, but keeps previous where clauses.
      `show_count` or `sc` - when this is set to true, the function will have to fetch an unlimited query in order to count the total pages, which is why over-use of this parameter is discouraged.
      """
      @moduledoc since: "1.1.0"
      def paginate(query, params \\ %{})

      def paginate(query, %{"paginate_clean" => "true", "paginate" => "false"}),
        do: @repo.all(query)

      def paginate(query, %{"pc" => "true", "paginate" => "false"}),
        do: @repo.all(query)

      def paginate(query, %{"paginate_clean" => true, "paginate" => false}), do: @repo.all(query)
      def paginate(query, %{paginate_clean: true, paginate: false}), do: @repo.all(query)

      def paginate(query, %{"paginate" => "false"}), do: %{list: @repo.all(query)}
      def paginate(query, %{"paginate" => false}), do: %{list: @repo.all(query)}
      def paginate(query, %{paginate: false}), do: %{list: @repo.all(query)}

      def paginate(query, params), do: start(query, params)

      def start(query, params) do
        # Get default field to order by for limit and offset to work
        field = field_handler(params)
        # Get limit from params or if it's not there kee default
        limit = limit_handler(params)
        # Get offset from params by way of page or offset fields or if not from default
        offset = offset_handler(params)

        unlimited =
          query
          # add filters to where if the string is not empty
          |> filter_handler(params)
          # add where to all string fields if search_string is not empty
          |> search_handler(params)

        unlimited
        # DONE: add more ordering options
        # adds fields from select or schema to order_bys if string is not empty
        |> order_handler(params, field)
        # add limit to query
        |> limit(^limit)
        # add offset to query
        |> offset(^offset)
        # DONE: add complex filters, custom search fields taken from a select query
        # get list and if needed total counts
        |> count_handler(unlimited, params, field, limit, offset)
      end

      defp order_handler(query, %{sort: sort}, _) when is_bitstring(sort),
        do: order_handler(query, %{"sort" => sort}, "")

      defp order_handler(query, %{"s" => sort}, _) when is_bitstring(sort),
        do: order_handler(query, %{"sort" => sort}, "")

      defp order_handler(query, %{"sort" => sort}, _) when is_bitstring(sort) do
        # split string in case of multiple sorting fields
        String.split(sort, "|")
        # update query in a reduce
        |> Enum.reduce(query, fn sort, query ->
          # get path to field and direction of sorting
          [field, direction] = String.split(sort, ":")

          if is_nil(query.select) do
            # if no select add ordering to first element of query
            order_by(query, [n], {^direction_helper(direction), field(n, ^field)})
          else
            try do
              # get field expression from select
              expression = get_field(field, query)

              Map.put(query, :order_bys, [
                %Ecto.Query.QueryExpr{
                  expr: [{direction_helper(direction), expression}],
                  file: "paginator",
                  line: 100,
                  params: []
                }
                | query.order_bys
              ])
            rescue
              _ -> query
            end
          end
        end)
      end

      # if no order is chosen apply default ordering to keep a working pagination
      defp order_handler(query, _, field) do
        order_by(query, [n], {:asc, field(n, ^field)})
      end

      # convert strings and abbreviations to atoms
      defp direction_helper(direction) when direction in [:asc, :desc], do: direction

      defp direction_helper(direction) when direction in ["asc", "desc"],
        do: String.to_atom(direction)

      defp direction_helper("a"), do: :asc

      defp direction_helper("d"), do: :desc

      defp direction_helper(_), do: :asc

      # don't compile if disabled
      if Keyword.get(opts, :enable_custom_field, true) do
        # change default field for ordering
        defp field_handler(%{"field" => field}) when is_bitstring(field),
          do: String.to_existing_atom(field)

        defp field_handler(%{field: field}) when is_bitstring(field),
          do: String.to_existing_atom(field)

        defp field_handler(%{"field" => field}), do: field
        defp field_handler(%{field: field}), do: field
      end

      defp field_handler(_), do: @field

      # don't compile if disabled
      if Keyword.get(opts, :enable_custom_limit, true) do
        # set limits from a string or integer and disable negative or 0 values
        defp limit_handler(%{"limit" => limit}) when limit <= 0, do: 1
        defp limit_handler(%{"l" => limit}) when limit <= 0, do: 1
        defp limit_handler(%{limit: limit}) when limit <= 0, do: 1

        defp limit_handler(%{"limit" => limit}) when is_bitstring(limit),
          do: abs(String.to_integer(limit))

        defp limit_handler(%{"l" => limit}) when is_bitstring(limit),
          do: abs(String.to_integer(limit))

        defp limit_handler(%{"limit" => limit}), do: limit
        defp limit_handler(%{"l" => limit}), do: limit
        defp limit_handler(%{limit: limit}), do: limit
      end

      defp limit_handler(_), do: @limit

      # set offset from a page or custom offset, restrict negative values
      defp offset_handler(%{"page" => page}) when page <= 0, do: 0
      defp offset_handler(%{"p" => page}) when page <= 0, do: 0
      defp offset_handler(%{page: page}) when page <= 0, do: 0

      defp offset_handler(%{"page" => page, "limit" => limit})
           when is_bitstring(page) and is_bitstring(limit),
           do: abs(String.to_integer(limit)) * abs(String.to_integer(page) - 1)

      defp offset_handler(%{"p" => page, "l" => limit})
           when is_bitstring(page) and is_bitstring(limit),
           do: abs(String.to_integer(limit)) * abs(String.to_integer(page) - 1)

      defp offset_handler(%{"page" => page, "limit" => limit}), do: limit * (page - 1)
      defp offset_handler(%{"p" => page, "l" => limit}), do: limit * (page - 1)
      defp offset_handler(%{page: page, limit: limit}), do: limit * (page - 1)

      defp offset_handler(%{"page" => page}) when is_bitstring(page),
        do: @limit * abs(String.to_integer(page) - 1)

      defp offset_handler(%{"p" => page}) when is_bitstring(page),
        do: @limit * abs(String.to_integer(page) - 1)

      defp offset_handler(%{"page" => page}), do: @limit * (page - 1)
      defp offset_handler(%{"p" => page}), do: @limit * (page - 1)
      defp offset_handler(%{page: page}), do: @limit * (page - 1)

      if Keyword.get(opts, :enable_custom_offset, false) do
        defp offset_handler(%{"offset" => offset}) when is_bitstring(offset),
          do: abs(String.to_integer(offset))

        defp offset_handler(%{"offset" => offset}), do: offset
        defp offset_handler(%{offset: offset}), do: offset
      end

      defp offset_handler(_), do: @offset

      defp filter_handler(query, %{filter: filter}) when is_bitstring(filter),
        do: filter_handler(query, %{"filter" => filter})

      defp filter_handler(query, %{"f" => filter}) when is_bitstring(filter),
        do: filter_handler(query, %{"filter" => filter})

      defp filter_handler(query, %{"filter" => filter}) when is_bitstring(filter) do
        # split string in case of multiple sorting fields
        String.split(filter, "|")
        # update query in a reduce
        |> Enum.reduce(query, fn filter, query ->
          # get path to field, value for filtering and type of filter
          [field, value, t] = String.split(filter, ":")

          if is_nil(query.select) do
            # get type of field from main schema
            {expr, params} =
              elem(query.from.source, 1).__schema__(:type, field)
              # get correct expression for where insertion
              |> filter_case({{:., [], [{:&, [], [0]}, field]}, [], []}, value, t)

            Map.put(query, :wheres, [
              %Ecto.Query.BooleanExpr{
                expr: expr,
                file: "paginator",
                line: 212,
                op: :and,
                params: [params],
                subqueries: []
              }
              | query.wheres
            ])
          else
            try do
              # get field from select expression
              {expr, params} =
                get_field(field, query)
                |> case do
                  # if from main schema
                  {{:., [], [{:&, [], [0]}, field]}, [], []} = expression ->
                    # same as above
                    elem(query.from.source, 1).__schema__(:type, field)
                    # same as above
                    |> filter_case(expression, value, t)

                  # if from joins
                  {{:., [], [{:&, [], [number]}, field]}, [], []} = expression ->
                    # get type of field from join schema
                    elem(Enum.at(query.joins, number - 1).source, 1).__schema__(:type, field)
                    # same as above
                    |> filter_case(expression, value, t)
                end

              Map.put(query, :wheres, [
                %Ecto.Query.BooleanExpr{
                  expr: expr,
                  file: "paginator",
                  line: 237,
                  op: :and,
                  params: [params],
                  subqueries: []
                }
                | query.wheres
              ])
            rescue
              _ -> query
            end
          end
        end)
      end

      defp filter_handler(query, _), do: query

      defp filter_case(result, expression, value, t) do
        if t in ["eql", "equallist", "difl", "differentlist"] and not is_list(value) do
          value =
          String.split(value, ",", trim: true)
          |> Enum.map(fn value ->
            type_filter(result, value)
          end)
          filter_case(result, expression, value, t)
        else
          case result do
            :string ->
              filter_helper(expression, value, :string, t)

            type when type in [:id, :integer] ->
              filter_helper(expression, type_filter(result, value), type, t)

            :date_time ->
              filter_helper(expression, type_filter(result, value), :date_time, t)

            :naive_date_time ->
              filter_helper(expression, type_filter(result, value), :naive_date_time, t)

            :date ->
              filter_helper(expression, type_filter(result, value), :date, t)

            :time ->
              filter_helper(expression, type_filter(result, value), :time, t)

            :boolean ->
              filter_helper(expression, type_filter(result, value), :boolean, t)

            :float ->
              filter_helper(expression, type_filter(result, value), :float, t)

            type ->
              type.type
              |> filter_case(expression, type_filter(result, value), t)
          end
        end
      end

      defp type_filter(_result, value) when is_list(value) do
        value
      end

      defp type_filter(result, value) do
        case result do
          :string ->
            value

          type when type in [:id, :integer] ->
            String.to_integer(value)

          :date_time ->
            DateTime.from_iso8601(value)

          :naive_date_time ->
            NaiveDateTime.from_iso8601!(value)

          :date ->
            Date.from_iso8601!(value)

          :time ->
            Time.from_iso8601!(value)

          :boolean ->
            String.to_atom(value)

          :float ->
            String.to_float(value)

          type ->
            value
        end
      end

      defp filter_helper(expression, value, type, t) when t in ["in", "inc", "includes"],
        do: {{:ilike, [], [expression, {:^, [], [0]}]}, {"%#{value}%", type}}

      defp filter_helper(expression, value, type, t) when t in ["ex", "exc", "excludes"],
        do: {{:not, [], [{:ilike, [], [expression, {:^, [], [0]}]}]}, {"%#{value}%", type}}

      defp filter_helper(expression, value, type, t) when t in ["eq", "equal"],
        do: {{:==, [], [expression, {:^, [], [0]}]}, {value, type}}

      defp filter_helper(expression, value, type, t) when t in ["dif", "different"],
        do: {{:!=, [], [expression, {:^, [], [0]}]}, {value, type}}

      defp filter_helper(expression, value, type, t) when t in ["eql", "equallist"],
        do: {{:in, [], [expression, {:^, [], [0]}]}, {value, {:in, type}}}

      defp filter_helper(expression, value, type, t) when t in ["difl", "differentlist"],
        do: {{:not, [], [{:in, [], [expression, {:^, [], [0]}]}]}, {value, {:in, type}}}

      defp filter_helper(expression, value, type, t) when t in ["mo", "more"],
        do: {{:>, [], [expression, {:^, [], [0]}]}, {value, type}}

      defp filter_helper(expression, value, type, t) when t in ["le", "less"],
        do: {{:<, [], [expression, {:^, [], [0]}]}, {value, type}}

      defp filter_helper(expression, value, type, t) when t in ["moq", "moreequal"],
        do: {{:>=, [], [expression, {:^, [], [0]}]}, {value, type}}

      defp filter_helper(expression, value, type, t) when t in ["leq", "lessequal"],
        do: {{:<=, [], [expression, {:^, [], [0]}]}, {value, type}}

      defp search_handler(query, %{"search_string" => string})
           when is_bitstring(string),
           do: search_helper(query, string)

      defp search_handler(query, %{"search" => string})
           when is_bitstring(string),
           do: search_helper(query, string)

      defp search_handler(query, %{"ss" => string})
           when is_bitstring(string),
           do: search_helper(query, string)

      defp search_handler(query, %{search_string: string})
           when is_bitstring(string),
           do: search_helper(query, string)

      defp search_handler(query, %{search: string})
           when is_bitstring(string),
           do: search_helper(query, string)

      defp search_handler(query, _), do: query

      defp search_helper(query, string) do
        # get all string fields from select expression or from main schema if no select clause present
        {fields, params} = field_helper(query, string)

        case length(fields) do
          0 ->
            # if no string fields return unchanged
            query

          1 ->
            # if one insert into wheres as boolean
            put_bool(query, hd(fields), params)

          _ ->
            # if multiple, reverse the order
            [first | fields] = Enum.reverse(fields)

            # insert them into wheres with an OR statement between them
            put_bool(
              query,
              Enum.reduce(
                fields,
                first,
                &{:or, [],
                 [
                   &2,
                   &1
                 ]}
              ),
              params
            )
        end
      end

      defp put_bool(query, fields, params),
        do:
          Map.put(query, :wheres, [
            %Ecto.Query.BooleanExpr{
              expr: fields,
              file: "paginator",
              line: 338,
              op: :and,
              params: params,
              subqueries: []
            }
            | query.wheres
          ])

      defp field_helper(%{from: %{source: {_, s}}, select: nil}, string),
        do:
          Enum.reduce(
            s.__schema__(:fields),
            {[], []},
            &if(s.__schema__(:type, &1) == :string,
              do:
                {[
                   {:ilike, [],
                    [{{:., [], [{:&, [], [0]}, &1]}, [], []}, {:^, [], [length(elem(&2, 0))]}]}
                   | elem(&2, 0)
                 ], [{"%#{string}%", :string} | elem(&2, 1)]},
              else: {elem(&2, 0), elem(&2, 1)}
            )
          )

      defp field_helper(
             %{select: %Ecto.Query.SelectExpr{expr: {:%{}, [], list}}} = query,
             string
           ),
           do:
             Enum.reduce(
               collect_fields(query, list),
               {[], []},
               &{[
                  {:ilike, [], [&1, {:^, [], [length(elem(&2, 0))]}]}
                  | elem(&2, 0)
                ], [{"%#{string}%", :string} | elem(&2, 1)]}
             )

      defp collect_fields(query, list, fields \\ []) do
        # recursively pull all string fields from select expression
        Enum.reduce(list, fields, fn
          {_, {:%{}, [], list}}, fields ->
            collect_fields(query, list, fields)

          {_, {:{}, [], list}}, fields ->
            collect_fields(query, list, fields)

          {_, {{:., [], [{:&, [], [0]}, field]}, [], []}}, fields ->
            collect_helper(query, field, fields, 0)

          {_, {{:., [], [{:&, [], [number]}, field]}, [], []}}, fields ->
            collect_helper(query, field, fields, number)

          {{:., [], [{:&, [], [0]}, field]}, [], []}, fields ->
            collect_helper(query, field, fields, 0)

          {{:., [], [{:&, [], [number]}, field]}, [], []}, fields ->
            collect_helper(query, field, fields, number)

          _, fields ->
            fields
        end)
      end

      # if from main schema
      defp collect_helper(%{from: %{source: {_, s}}} = query, field, fields, 0) do
        if s.__schema__(:type, field) == :string do
          [{{:., [], [{:&, [], [0]}, field]}, [], []} | fields]
        else
          fields
        end
      end

      # if from join schema
      defp collect_helper(query, field, fields, number) do
        try do
          if elem(Enum.at(query.joins, number - 1).source, 1).__schema__(:type, field) ==
               :string do
            [{{:., [], [{:&, [], [number]}, field]}, [], []} | fields]
          else
            fields
          end
        rescue
          _ -> fields
        end
      end

      defp count_handler(
             query,
             _unlimited,
             %{"paginate_clean" => "true"},
             _field,
             _limit,
             _offset
           ),
           do: @repo.all(query)

      defp count_handler(query, _unlimited, %{"paginate_clean" => true}, _field, _limit, _offset),
        do: @repo.all(query)

      defp count_handler(query, _unlimited, %{"pc" => true}, _field, _limit, _offset),
        do: @repo.all(query)

      defp count_handler(query, _unlimited, %{paginate_clean: true}, _field, _limit, _offset),
        do: @repo.all(query)

      defp count_handler(query, unlimited, %{"show_count" => "true"}, field, limit, offset) do
        count_helper(query, unlimited, field, limit, offset)
      end

      defp count_handler(query, unlimited, %{"sc" => "true"}, field, limit, offset) do
        count_helper(query, unlimited, field, limit, offset)
      end

      defp count_handler(query, unlimited, %{"show_count" => true}, field, limit, offset) do
        count_helper(query, unlimited, field, limit, offset)
      end

      defp count_handler(query, unlimited, %{show_count: true}, field, limit, offset) do
        count_helper(query, unlimited, field, limit, offset)
      end

      if Keyword.get(opts, :show_count_by_default, false) do
        defp count_handler(query, _unlimited, %{show_count: false}, _field, limit, offset),
          do: %{list: @repo.all(query), page: round(offset / limit) + 1}

        defp count_handler(query, unlimited, _, field, limit, offset) do
          count_helper(query, unlimited, field, limit, offset)
        end
      else
        defp count_handler(query, _unlimited, _, _field, limit, offset),
          do: %{list: @repo.all(query), page: round(offset / limit) + 1}
      end

      defp count_helper(query, unlimited, field, limit, offset) do
        # I can't use aggregate because some queries can have filters or join clauses that limit the amount of values
        unlimited = length(@repo.all(unlimited))

        %{
          list: @repo.all(query),
          page: floor(offset / limit) + 1,
          page_count: ceil(unlimited / limit),
          total_count: unlimited
        }
      end

      defp get_field(path, query) do
        String.split(path, ".")
        |> Enum.reduce(query.select.expr, fn
          field, {:%{}, [], list} ->
            Keyword.get(list, String.to_existing_atom(field))
        end)
      end
    end
  end
end