lib/record_list.ex

defmodule RecordList do
  @moduledoc """
  A struct that builds a list of records defined by a query by initialising with a set of parameters and passing the struct through a sequence of steps.
  A pagination struct `RecordList.Pagination` is built to capture the paging information.

  The libary is built to be open-ended but a typical pipeline could look something like:

  ```
  params -> base query -> apply sorting -> search against some criteria
    -> apply filtering -> calculate paging values -> retrieve the records.
  ```

  A RecordList can be created by calling

  ```elixir
    use RecordList,
      steps: [
        base: [impl: MyBaseStep],
        sort: [impl: MySortStep, default_sort: "name", default_order: "asc"],
        paginate: [impl: MyPaginationStep, per_page: 20, count_by: :id, repo: MyApp.Repo]
        retrieve: [impl: MyRetrieveStep]
      ]
  ```

  ## Steps

  Steps implement the `RecordList.StepBehaviour` behaviour.
  The `execute/3` function takes the record list in the making, the step name as an atom and any options that were passed in, for example, the `default_sort` and `default_order` options above.

  Steps are executed in the order in which they are defined in the `:steps` option to the `RecordList.__using__/1` macro.
  Calling a step will execute all the steps higher in the list of steps.

  ```elixir
    %RecordList{params: ^params, query: _base_query, steps: [:base]}
      = MyRecordList.base(params)
    %RecordList{loaded: true, records: _populated_with_results_of_query, params: ^params, query: _, steps: [:retrieve, :paginate, :sort, :base]}
      = MyRecordList.retrieve(params)
  ```

  Notice that calling `retrieve/1` returns a record list with the prior steps executed as well. Calling `paginate/1` will build the pagination struct, but not retrieve the records.

  ```elixir
    %RecordList{loaded: false, records: [], pagination: %RecordList.Pagination{records_count: _, records_offset: _}, steps: [:retrieve, :paginate, :sort, :base]}
      = MyRecordList.paginate(params)
  ```

  See `__struct__/0` for details about the attributes.
  """

  @typedoc """
  The RecordList struct collects meta data along with the list of records.

  ## Attributes
  * `:query`- the variable where the query is built up. In the case of Ecto this will be an Ecto.Query struct.
  * `:params`- Queries to create a record list are typically driven by parameters. These can be captured in the `params` attribute to be referenced in subsequent steps.
  * `:pagination`- paginating a list of data is common. RecordList comes with a `%RecordList.Pagination{}` struct that can be used to capture information describing the pages in the list.
  * `:loaded`- a boolean value indiciating whether the records have been loaded. An empty list of records does not capture the same information since the result of a query can be an empty list.
  * `:records`- a list of records retrieved by executing the query.
  * `:steps`- a list of the steps that have been executed. This makes it possible to not have to run a step.
  * `:extra`- a map of any extra information that might be needed along the way.
  """
  @type t :: %RecordList{}

  defstruct [:query, :params, :pagination, loaded: false, records: [], steps: [], extra: %{}]

  @doc false
  def add_step(%__MODULE__{steps: [step | _steps]} = record_list, step), do: record_list

  def add_step(%__MODULE__{steps: steps} = record_list, step) do
    # This clause should not be necessary since steps should be called in sequence.
    if Enum.member?(steps, step) do
      record_list
    else
      %{record_list | steps: [step | steps]}
    end
  end

  defmacro __using__(opts) do
    steps = Keyword.get(opts, :steps, [])
    step_keys = Keyword.keys(steps)

    # This defines the transformation steps that are executed on the initial record_list, usually to modify
    # the record_list.query which is used to retrieve the data in the `:retrieve` step.
    steps
    |> Enum.map(fn
      {step, step_opts} ->
        {impl, other_opts} = Keyword.pop!(step_opts, :impl)

        quote do
          def unquote(step)(%RecordList{} = record_list) do
            apply(unquote(impl), :execute, [record_list, unquote(step), unquote(other_opts)])
            |> RecordList.add_step(unquote(step))
          end

          def unquote(step)(params) when is_map(params) do
            unquote(step_keys)
            |> Enum.reduce_while(%RecordList{params: params}, fn
              unquote(step), record_list ->
                {:halt, record_list}

              missing_step, record_list ->
                {:cont, step(record_list, missing_step)}
            end)
            |> step(unquote(step))
          end

          def step(%RecordList{} = record_list, unquote(step)) do
            apply(__MODULE__, unquote(step), [record_list])
          end

          def step(params, unquote(step)) when is_map(params) do
            apply(__MODULE__, unquote(step), [params])
          end
        end
    end)
  end
end