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