lib/ecto_resource.ex

defmodule EctoResource do
  @moduledoc """
  EctoResource
  ============
  Eliminate boilerplate involved in defining basic CRUD functions in a Phoenix context or Elixir module.

  When using [Context modules](https://hexdocs.pm/phoenix/contexts.html) in a [Phoenix](https://phoenixframework.org/) application,
  there's a general need to define the standard CRUD functions for a given `Ecto.Schema`. Phoenix context generators will even do this automatically.
  Soon you will notice that there's quite a lot of code involved in CRUD access within your contexts.

  This can become problematic for a few reasons:

  * Boilerplate functions for CRUD access, for every `Ecto.Schema` referenced in that context, introduce more noise than signal. This can obscure the more interesting details of the context.
  * These functions may tend to accumulate drift from the standard API by inviting edits for new use-cases, reducing the usefulness of naming conventions.
  * The burden of locally testing wrapper functions, yields low value for the writing and maintainence investment.

  In short, at best this code is redundant and at worst is a deviant entanglement of modified conventions. All of which amounts to a more-painful development experience. `EctoResource` was created to ease this pain.

  Usage
  -----

  ### Basic usage - generate all `EctoResource` functions

  ```elixir
  defmodule MyApp.MyContext do
  alias MyApp.Repo
  alias MyApp.Schema
  use EctoResource

  using_repo(Repo) do
    resource(Schema)
  end
  end
  ```

  This generates all the functions `EctoResource` has to offer:

  * `MyContext.all_schemas/1`
  * `MyContext.change_schema/1`
  * `MyContext.create_schema/1`
  * `MyContext.create_schema!/1`
  * `MyContext.delete_schema/1`
  * `MyContext.delete_schema!/1`
  * `MyContext.get_schema/2`
  * `MyContext.get_schema!/2`
  * `MyContext.get_schema_by/2`
  * `MyContext.get_schema_by!/2`
  * `MyContext.update_schema/2`
  * `MyContext.update_schema!/2`

  ### Explicit usage - generate only given functions

  ```elixir
  defmodule MyApp.MyContext do
  alias MyApp.Repo
  alias MyApp.Schema
  use EctoResource

  using_repo(Repo) do
    resource(Schema, only: [:create, :delete!])
  end
  end
  ```

  This generates only the given functions:

  * `MyContext.create_schema/1`
  * `MyContext.delete_schema!/1`

  ### Exclusive usage - generate all but the given functions

  ```elixir
  defmodule MyApp.MyContext do
  alias MyApp.Repo
  alias MyApp.Schema
  use EctoResource

  using_repo(Repo) do
    resource(Schema, except: [:create, :delete!])
  end
  end
  ```

  This generates all the functions excluding the given functions:

  * `MyContext.all_schemas/1`
  * `MyContext.change_schema/1`
  * `MyContext.create_schema!/1`
  * `MyContext.delete_schema/1`
  * `MyContext.get_schema/2`
  * `MyContext.get_schema_by/2`
  * `MyContext.get_schema_by!/2`
  * `MyContext.get_schema!/2`
  * `MyContext.update_schema/2`
  * `MyContext.update_schema!/2`

  ### Alias `:read` - generate data access functions

  ```elixir
  defmodule MyApp.MyContext do
  alias MyApp.Repo
  alias MyApp.Schema
  use EctoResource

  using_repo(Repo) do
    resource(Schema, :read)
  end
  end
  ```

  This generates all the functions necessary for reading data:

  * `MyContext.all_schemas/1`
  * `MyContext.get_schema/2`
  * `MyContext.get_schema!/2`

  ### Alias `:read_write` - generate data access and manipulation functions, excluding delete

  ```elixir
  defmodule MyApp.MyContext do
  alias MyApp.Repo
  alias MyApp.Schema
  use EctoResource

  using_repo(Repo) do
    resource(Schema, :read_write)
  end
  end
  ```

  This generates all the functions except `delete_schema/1` and `delete_schema!/1`:

  * `MyContext.all_schemas/1`
  * `MyContext.change_schema/1`
  * `MyContext.create_schema/1`
  * `MyContext.create_schema!/1`
  * `MyContext.get_schema/2`
  * `MyContext.get_schema!/2`
  * `MyContext.update_schema/2`
  * `MyContext.update_schema!/2`

  ### Resource functions

  The general idea of the generated resource functions is to abstract away the `Ecto.Repo` and `Ecto.Schema` parts of data access with `Ecto` and provide an API to the context that feels natural and clear to the caller.

  The following examples will all assume a repo named `Repo` and a schema named `Person`.

  #### all_people

  Fetches a list of all %Person{} entries from the data store. _Note: `EctoResource` will pluralize this function name using `Inflex`_

  ```elixir
  iex> all_people()
  [%Person{id: 1}]

  iex> all_people(preloads: [:address])
  [%Person{id: 1, address: %Address{}}]

  iex> all_people(order_by: [desc: :id])
  [%Person{id: 2}, %Person{id: 1}]

  iex> all_people(preloads: [:address], order_by: [desc: :id]))
  [
  %Person{
    id: 2,
    address: %Address{}
  },
  %Person{
    id: 1,
    address: %Address{}
  }
  ]

  iex> all_people(where: [id: 2])
  [%Person{id: 2, address: %Address{}}]
  ```

  #### change_person

  Creates a `%Person{}` changeset.

  ```elixir
  iex> change_person(%{name: "Example Person"})
  #Ecto.Changeset<
  action: nil,
  changes: %{name: "Example Person"},
  errors: [],
  data: #Person<>,
  valid?: true
  >
  ```

  #### create_person

  Inserts a `%Person{}` with the given attributes in the data store, returning an `:ok`/`:error` tuple.

  ```elixir
  iex> create_person(%{name: "Example Person"})
  {:ok, %Person{id: 123, name: "Example Person"}}

  iex> create_person(%{invalid: "invalid"})
  {:error, %Ecto.Changeset}
  ```

  #### create_person!

  Inserts a `%Person{}` with the given attributes in the data store, returning a `%Person{}` or raises `Ecto.InvalidChangesetError`.

  ```elixir
  iex> create_person!(%{name: "Example Person"})
  %Person{id: 123, name: "Example Person"}

  iex> create_person!(%{invalid: "invalid"})
  ** (Ecto.InvalidChangesetError)
  ```

  #### delete_person

  Deletes a given `%Person{}` from the data store, returning an `:ok`/`:error` tuple.

  ```elixir
  iex> delete_person(%Person{id: 1})
  {:ok, %Person{id: 1}}

  iex> delete_person(%Person{id: 999})
  {:error, %Ecto.Changeset}
  ```

  #### delete_person!

  Deletes a given `%Person{}` from the data store, returning the deleted `%Person{}`, or raises `Ecto.StaleEntryError`.

  ```elixir
  iex> delete_person!(%Person{id: 1})
  %Person{id: 1}

  iex> delete_person!(%Person{id: 999})
  ** (Ecto.StaleEntryError)
  ```

  #### get_person

  Fetches a single `%Person{}` from the data store where the primary key matches the given id, returns a `%Person{}` or `nil`.

  ```elixir
  iex> get_person(1)
  %Person{id: 1}

  iex> get_person(999)
  nil

  iex> get_person(1, preloads: [:address])
  %Person{
    id: 1,
    address: %Address{}
  }
  ```

  #### get_person!

  Fetches a single `%Person{}` from the data store where the primary key matches the given id, returns a `%Person{}` or raises `Ecto.NoResultsError`.

  ```elixir
  iex> get_person!(1)
  %Person{id: 1}

  iex> get_person!(999)
  ** (Ecto.NoResultsError)

  iex> get_person!(1, preloads: [:address])
  %Person{
    id: 1,
    address: %Address{}
  }
  ```

  #### get_person_by

  Fetches a single `%Person{}` from the data store where the attributes match the
  given values.

  ```elixir
  iex> get_person_by(%{name: "Chuck Norris"})
  %Person{name: "Chuck Norris"}

  iex> get_person_by(%{name: "Doesn't Exist"})
  nil
  ```

  #### get_person_by!

  Fetches a single `%Person{}` from the data store where the attributes match the
  given values. Raises an `Ecto.NoResultsError` if the record does not exist

  ```elixir
  iex> get_person_by!(%{name: "Chuck Norris"})
  %Person{name: "Chuck Norris"}

  iex> get_person_by!(%{name: "Doesn't Exist"})
  ** (Ecto.NoResultsError)
  ```

  #### update_person

  Updates a given %Person{} with the given attributes, returns an `:ok`/`:error` tuple.

  ```elixir
  iex> update_person(%Person{id: 1}, %{name: "New Person"})
  {:ok, %Person{id: 1, name: "New Person"}}

  iex> update_person(%Person{id: 1}, %{invalid: "invalid"})
  {:error, %Ecto.Changeset}
  ```

  #### update_person!

  Updates a given %Person{} with the given attributes, returns a %Person{} or raises `Ecto.InvalidChangesetError`.

  ```elixir
  iex> update_person!(%Person{id: 1}, %{name: "New Person"})
  %Person{id: 1, name: "New Person"}

  iex> update_person!(%Person{id: 1}, %{invalid: "invalid"})
  ** (Ecto.InvalidChangesetError)
  ```

  Caveats
  -------
  This is not meant to be used as a wrapper for all the Repo functions within a context. Not all callbacks defined in Ecto.Repo are generated. `EctoResource` should be used to help reduce boilerplate code and tests for general CRUD operations.

  It may be the case that `EctoResource` needs to evolve and provide slightly more functionality/flexibility in the future. However, the general focus is reducing boilerplate code.
  """

  alias __MODULE__
  alias EctoResource.Helpers
  alias EctoResource.OptionParser
  alias EctoResource.ResourceFunctions

  @doc """
  Macro to import `EctoResource.using_repo/2`

  ## Examples
      use EctoResource
  """
  defmacro __using__(_) do
    quote do
      import EctoResource, only: [using_repo: 2]
    end
  end

  @doc """
  Macro to define schema access within a given `Ecto.Repo`

  ## Examples
      using_repo(Repo) do
        resource(Schema)
      end
  """
  defmacro using_repo(repo, do: block) do
    quote do
      Module.register_attribute(__MODULE__, :repo, [])
      Module.put_attribute(__MODULE__, :repo, unquote(repo))
      Module.register_attribute(__MODULE__, :resources, accumulate: true)
      import EctoResource, only: [resource: 2, resource: 1]
      unquote(block)
      def __resource__(:resources), do: @resources
      Module.delete_attribute(__MODULE__, :resources)
      Module.delete_attribute(__MODULE__, :repo)
    end
  end

  @doc """
  Macro to define CRUD methods for the given `Ecto.Repo` in the using module.

  ## Examples
      using(Repo) do
        resource(Schema)
      end

      using(Repo) do
        resource(Schema, suffix: false)
      end

      using(Repo) do
        resource(Schema, only: [:get])
      end

      using(Repo) do
        resource(Schema, except: [:delete])
      end

      using(Repo) do
        resource(Schema, :read)
      end

      using(Repo) do
        resource(Schema, :write)
      end

      using(Repo) do
        resource(Schema, :delete)
      end
  """
  # credo:disable-for-next-line
  defmacro resource(schema, options \\ []) do
    # credo:disable-for-next-line
    quote bind_quoted: [schema: schema, options: options] do
      suffix = OptionParser.create_suffix(schema, options)
      schema_name = Helpers.schema_name(schema)
      resources = OptionParser.parse(suffix, options)
      descriptions = Helpers.resource_descriptions(resources)

      Module.put_attribute(__MODULE__, :resources, {@repo, schema, descriptions})

      Enum.each(resources, fn {action, %{name: name}} ->
        case action do
          :all ->
            @doc """
            Fetches all #{schema_name} entries from the data store.

            ## Examples
                #{name}()
                [%#{schema_name}{id: 123}]

                #{name}(preloads: [:relation])
                [%#{schema_name}{id: 123, relation: %Relation{}}]

                #{name}(order_by: [desc: :id])
                [%#{schema_name}{id: 2}, %#{schema_name}{id: 1}]

                #{name}(preloads: [:relation], order_by: [desc: :id])
                [
                  %#{schema_name}{
                    id: 2,
                    relation: %Relation{}
                  },
                  %#{schema_name}{
                    id: 1,
                    relation: %Relation{}
                  }
                ]
            """
            @spec unquote(name)(keyword(list())) :: list(Ecto.Schema.t())
            def unquote(name)(options \\ []) do
              ResourceFunctions.all(@repo, unquote(schema), options)
            end

          :change ->
            @doc """
            Creates a #{schema_name} changeset from an existing schema struct.

                #{name}(%#{schema_name}{}, %{})

                #Ecto.Changeset<
                  action: nil,
                  changes: %{},
                  errors: [],
                  data: ##{schema_name}<>,
                  valid?: true
                >

                #{name}(%#{schema_name}{}, [])

                #Ecto.Changeset<
                  action: nil,
                  changes: %{},
                  errors: [],
                  data: ##{schema_name}<>,
                  valid?: true
                >

            """
            @spec unquote(name)(Ecto.Schema.t(), map() | Keyword.t()) :: Ecto.Changeset.t()
            def unquote(name)(changeable, changes) do
              ResourceFunctions.change(unquote(schema), changeable, changes)
            end

          :changeset ->
            @doc """
            Creates a blank changeset.

                changeset()

                #Ecto.Changeset<
                  action: nil,
                  changes: %{},
                  errors: [],
                  data: ##{schema_name}<>,
                  valid?: true
                >
            """
            @spec unquote(name)() :: Ecto.Changeset.t()
            def unquote(name)() do
              ResourceFunctions.changeset(unquote(schema))
            end

          :create ->
            @doc """
            Inserts a #{schema_name} with the given attributes in the data store.

            ## Examples
                #{name}(%{})
                {:ok, %#{schema_name}{}}

                #{name}([])
                {:ok, %#{schema_name}{}}

                #{name}(%{invalid: "invalid"})
                  {:error, %Ecto.Changeset{}}
            """
            @spec unquote(name)(map() | Keyword.t()) ::
                    {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
            def unquote(name)(attributes) do
              ResourceFunctions.create(@repo, unquote(schema), attributes)
            end

          :create! ->
            @doc """
            Same as create_#{suffix}/1 but returns the struct or raises if the changeset is invalid.

            ## Examples
                #{name}(%{})
                %#{schema_name}{}

                #{name}([])
                %#{schema_name}{}

                #{name}(%{invalid: "invalid"})
                ** (Ecto.InvalidChangesetError)
            """
            @spec unquote(name)(map() | Keyword.t()) ::
                    Ecto.Schema.t() | Ecto.InvalidChangesetError
            def unquote(name)(attributes) do
              ResourceFunctions.create!(@repo, unquote(schema), attributes)
            end

          :delete ->
            @doc """
            Deletes a given %#{schema_name}{} from the data store.

            ## Examples
                #{name}(%#{schema_name}{id: 123})
                {:ok, %#{schema_name}{id: 123}}

                #{name}(%#{schema_name}{id: 456})
                {:error, %Ecto.Changeset{}}

            """
            @spec unquote(name)(Ecto.Schema.t()) ::
                    {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
            def unquote(name)(struct) do
              ResourceFunctions.delete(@repo, struct)
            end

          :delete! ->
            @doc """
            Same as delete_#{suffix}/1 but returns the struct or raises if the changeset is invalid.

            ## Examples
                #{name}(%#{schema_name}{id: 123})
                %#{schema_name}{id: 123}

                #{name}(%#{schema_name}{id: 456})
                ** (Ecto.StaleEntryError)
            """
            def unquote(name)(struct) do
              ResourceFunctions.delete!(@repo, struct)
            end

          :get ->
            @doc """
            Fetches a single #{schema_name} from the data store where the primary key matches the given id.

            ## Examples
                #{name}(123)
                %#{schema_name}{id: 123}

                #{name}(456)
                nil

                #{name}(123, preloads: [:relation])
                %#{schema_name}{
                  id: 1,
                  relation: %Relation{}
                }
            """
            @spec unquote(name)(String.t() | integer(), keyword(list())) :: Ecto.Schema.t() | nil
            def unquote(name)(id, options \\ []) do
              ResourceFunctions.get(@repo, unquote(schema), id, options)
            end

          :get! ->
            @doc """
            Same as get_#{suffix}/2 but raises Ecto.NoResultsError if no record was found.

            ## Examples
                #{name}(123)
                %#{schema_name}{id: 123}

                #{name}(456)
                ** (Ecto.NoResultsError)

                #{name}(123, preloads: [:relation])
                %#{schema_name}{
                  id: 1,
                  relation: %Relation{}
                }
            """
            def unquote(name)(id, options \\ []) do
              ResourceFunctions.get!(@repo, unquote(schema), id, options)
            end

          :get_by ->
            @doc """
            Fetches a single result from the query.

            Returns nil if no result was found. Raises if more than one entry.

            ## Examples
                #{name}(name: "Some Name")
                %#{schema_name}{name: "Some Name"}

                #{name}(%{name: "Some Name"})
                %#{schema_name}{name: "Some Name"}

                #{name}(name: "Missing")
                nil
            """
            def unquote(name)(attributes, options \\ []) do
              ResourceFunctions.get_by(@repo, unquote(schema), attributes, options)
            end

          :get_by! ->
            @doc """
            Similar to get_by/2 but raises Ecto.NoResultsError if no record was found.

            Raises if more than one entry.

            ## Examples
                #{name}(name: "Some Name")
                %#{schema_name}{name: "Some Name"}

                #{name}(%{name: "Some Name"})
                %#{schema_name}{name: "Some Name"}

                #{name}(name: "Missing")
                ** (Ecto.NoResultsError)
            """
            def unquote(name)(attributes, options \\ []) do
              ResourceFunctions.get_by!(@repo, unquote(schema), attributes, options)
            end

          :update ->
            @doc """
            Updates a %#{schema_name}{} with the given attributes.

            ## Examples
                #{name}(%#{schema_name}{id: 123}, %{attribute: "updated attribute"})
                {:ok, %#{schema_name}{id: 123, attribute: "updated attribute"}}

                #{name}(%#{schema_name}{id: 123}, attribute: "updated attribute")
                {:ok, %#{schema_name}{id: 123, attribute: "updated attribute"}}

                #{name}(%#{schema_name}{id: 123}, %{}, force: true)
                {:ok, %#{schema_name}{id: 123, attribute: "updated attribute"}}

                #{name}(%#{schema_name}{id: 123}, %{}, prefix: "my_prefix")
                {:ok, %#{schema_name}{id: 123, attribute: "updated attribute"}}

                #{name}(%#{schema_name}{id: 123}, %{invalid: "invalid"})
                {:error, %Ecto.Changeset{}}
            """
            @spec unquote(name)(Ecto.Schema.t(), map() | Keyword.t()) ::
                    {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
            def unquote(name)(struct, attributes) do
              ResourceFunctions.update(@repo, unquote(schema), struct, attributes)
            end

            @doc """
            Same as update_#{suffix}/2 returns a %#{schema_name}{} or raises if the changeset is invalid.

            ## Examples
                #{name}(%#{schema_name}{id: 123}, %{attribute: "updated attribute"})
                %#{schema_name}{id: 123, attribute: "updated attribute"}

                #{name}(%#{schema_name}{id: 123}, %{}, force: true)
                %#{schema_name}{id: 123, attribute: "updated attribute"}

                #{name}(%#{schema_name}{id: 123}, %{}, prefix: "my_prefix")
                %#{schema_name}{id: 123, attribute: "updated attribute"}

                #{name}(%#{schema_name}{id: 123}, %{invalid: "invalid"})
                ** (Ecto.InvalidChangesetError)
            """

          :update! ->
            @spec unquote(name)(Ecto.Schema.t(), map() | Keyword.t()) ::
                    Ecto.Schema.t() | Ecto.InvalidChangesetError
            def unquote(name)(struct, attributes) do
              ResourceFunctions.update!(@repo, unquote(schema), struct, attributes)
            end

          _ ->
            nil
        end
      end)
    end
  end
end