defmodule EctoShorts.Actions do
@moduledoc """
Actions for CRUD in ecto, these can be used by all schemas/queries
"""
@type query :: Ecto.Query | Ecto.Schema
@type filter_params :: Keyword.t | map
@type opts :: Keyword.t
@type aggregate_options :: :avg | :count | :max | :min | :sum
@type schema_list :: list(Ecto.Schema.t)
@type schema_res :: {:ok, Ecto.Schema.t} | {:error, any}
@type id :: pos_integer
@type schema :: Ecto.Schema.t() | module()
@type schema_data :: Ecto.Schema.t()
@type updates :: map() | Keyword.t()
alias EctoShorts.{CommonFilters, Actions.Error, Config}
@doc """
Gets a schema from the database
## Examples
iex> user = create_user()
iex> %{id: id} = EctoSchemas.Actions.get(EctoSchemas.Accounts.User, user.id)
iex> id === user.id
true
iex> EctoSchemas.Actions.get(EctoSchemas.Accounts.User, 2504390) # ID nonexistant
nil
"""
@spec get(queryable :: query, id :: term, options :: Keyword.t) :: Ecto.Schema.t | nil
@spec get(queryable :: query, id :: term) :: Ecto.Schema.t | nil
def get(schema, id, opts \\ []) do
replica!(opts).get(schema, id, opts)
end
@doc """
Gets a collection of schemas from the database
## Examples
iex> EctoSchemas.Actions.all(EctoSchemas.Accounts.User)
[]
"""
@spec all(queryable :: query) :: schema_list
def all(query) do
all(query, default_opts())
end
@doc """
Gets a collection of schemas from the database but allows for a filter or
an options list.
## Options
* `:repo` - A module that uses the Ecto.Repo Module.
## Examples
iex> Enum.each(1..4, fn _ -> create_user() end)
iex> length(EctoSchemas.Actions.all(EctoSchemas.Accounts.User, first: 3)) === 3
true
iex> Enum.each(1..4, fn _ -> create_user() end)
iex> length(EctoSchemas.Actions.all(EctoSchemas.Accounts.User, repo: MyApp.MyRepoModule.Repo)) === 3
true
"""
@spec all(queryable :: query, filter_params | opts) :: schema_list
def all(query, params) when is_map(params) do
all(query, params, default_opts())
end
def all(query, opts) do
query_params = Keyword.drop(opts, [:repo, :replica])
if Enum.any?(query_params) do
all(query, query_params, default_opts())
else
all(query, %{}, Keyword.take(opts, [:repo, :replica]))
end
end
@doc """
Similar to `all/2` but can also accept a keyword options list.
## Options
* `:repo` - A module that uses the Ecto.Repo Module.
## Examples
iex> Enum.each(1..4, fn _ -> create_user() end)
iex> length(EctoSchemas.Actions.all(EctoSchemas.Accounts.User, first: 3, repo: MyApp.MyRepoModule.Repo)) === 3
true
"""
@spec all(queryable :: query, params :: filter_params, opts) :: schema_list
def all(query, params, opts) do
order_by = Keyword.get(opts, :order_by, nil)
params = if order_by, do: Map.put(params || %{}, :order_by, order_by), else: params
replica!(opts).all(
CommonFilters.convert_params_to_filter(query, params),
opts
)
end
@doc """
Finds a schema with matching params. Can also accept a keyword options list.
## Options
* `:repo` - A module that uses the Ecto.Repo Module.
## Examples
iex> user = create_user()
iex> {:ok, schema} = EctoSchemas.Actions.find(EctoSchemas.Accounts.User, first_name: user.first_name)
iex> schema.first_name === user.first_name
true
iex> user = create_user()
iex> {:ok, schema} = EctoSchemas.Actions.find(EctoSchemas.Accounts.User, first_name: user.first_name, repo: MyApp.MyRepoModule.Repo)
iex> schema.first_name === user.first_name
true
"""
@spec find(queryable :: query, params :: filter_params, opts) :: schema_res | {:error, any}
@spec find(queryable :: query, params :: filter_params) :: schema_res | {:error, any}
def find(query, params, opts \\ [])
def find(query, params, _options) when params === %{} and is_atom(query) do
{:error, Error.call(:not_found, "no records found", %{
query: query,
params: params
})}
end
def find(query, params, opts) do
order_by = Keyword.get(opts, :order_by, nil)
params = if order_by, do: Map.put(params || %{}, :order_by, order_by), else: params
query
|> CommonFilters.convert_params_to_filter(params)
|> replica!(opts).one(opts)
|> case do
nil ->
{:error, Error.call(:not_found, "no records found", %{
query: query,
params: params
})}
schema -> {:ok, schema}
end
end
@doc """
Creates a schema with given params. Can also accept a keyword options list.
## Options
* `:repo` - A module that uses the Ecto.Repo Module.
## Examples
iex> {:ok, schema} = EctoSchemas.Actions.create(EctoSchemas.Accounts.User, user_params(first_name: "TEST"))
iex> schema.first_name
"TEST"
iex> {:error, changeset} = EctoSchemas.Actions.create(EctoSchemas.Accounts.User, Map.delete(user_params(), :first_name))
iex> "can't be blank" in errors_on(changeset).first_name
true
## Examples
iex> {:ok, schema} = EctoSchemas.Actions.create(EctoSchemas.Accounts.User, user_params(first_name: "TEST"), repo: MyApp.MyRepoModule.Repo)
iex> schema.first_name
true
"""
@spec create(schema :: Ecto.Schema.t, params :: filter_params, opts) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
@spec create(schema :: Ecto.Schema.t, params :: filter_params) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
def create(schema, params, opts \\ []) do
repo!(opts).insert(create_changeset(params, schema), opts)
end
@doc """
Finds a schema by params or creates one if it isn't found.
Can also accept a keyword options list.
***Note: Relational filtering doesn't work on this function***
## Options
* `:repo` - A module that uses the Ecto.Repo Module.
* `:replica` - If you don't want to perform any reads against your Primary, you can specify a replica to read from.
## Examples
iex> {:ok, schema} = EctoSchemas.Actions.find_or_create(EctoSchemas.Accounts.User, %{name: "great name"})
iex> {:ok, schema} = EctoSchemas.Actions.find_or_create(EctoSchemas.Accounts.User, %{name: "great name"}, repo: MyApp.MyRepoModule.Repo, replica: MyApp.MyRepoModule.Repo.replica())
"""
@spec find_or_create(Ecto.Schema.t, map, opts) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
@spec find_or_create(Ecto.Schema.t, map) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
def find_or_create(schema, params, opts \\ []) do
find_params = Map.drop(params, schema.__schema__(:associations))
with {:error, %{code: :not_found}} <- find(schema, find_params, opts) do
create(schema, params, opts)
end
end
@doc """
Finds a schema by params and updates it or creates with results of
params/update_params merged. Can also accept a keyword options list.
***Note: Relational filtering doesn't work on this function***
## Options
* `:repo` - A module that uses the Ecto.Repo Module.
* `:replica` - If you don't want to perform any reads against your Primary, you can specify a replica to read from.
## Examples
iex> {:ok, schema} = EctoSchemas.Actions.find_and_update(EctoSchemas.Accounts.User, %{email: "some_email"}, %{name: "great name"})
iex> {:ok, schema} = EctoSchemas.Actions.find_and_update(EctoSchemas.Accounts.User, %{email: "some_email"}, %{name: "great name}, repo: MyApp.MyRepoModule.Repo, replica: MyApp.MyRepoModule.Repo.replica())
"""
@spec find_and_update(Ecto.Schema.t(), map, map, opts) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
def find_and_update(schema, params, update_params, opts \\ []) do
find_params = Map.drop(params, schema.__schema__(:associations))
with {:ok, transaction} <- find(schema, find_params, opts) do
update(schema, transaction, update_params, opts)
end
end
@doc """
Finds a schema by params and updates it or creates with results of
params/update_params merged. Can also accept a keyword options list.
***Note: Relational filtering doesn't work on this function***
## Options
* `:repo` - A module that uses the Ecto.Repo Module.
* `:replica` - If you don't want to perform any reads against your Primary, you can specify a replica to read from.
## Examples
iex> {:ok, schema} = EctoSchemas.Actions.find_and_upsert(EctoSchemas.Accounts.User, %{email: "some_email"}, %{name: "great name"})
iex> {:ok, schema} = EctoSchemas.Actions.find_and_upsert(EctoSchemas.Accounts.User, %{email: "some_email"}, %{name: "great name}, repo: MyApp.MyRepoModule.Repo, replica: MyApp.MyRepoModule.Repo.replica())
"""
@spec find_and_upsert(Ecto.Schema.t(), map, map, opts) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
def find_and_upsert(schema, params, update_params, opts \\ []) do
find_params = Map.drop(params, schema.__schema__(:associations))
case find(schema, find_params, opts) do
{:ok, transaction} -> update(schema, transaction, update_params, opts)
{:error, %{code: :not_found}} -> create(schema, Map.merge(params, update_params), opts)
e -> e
end
end
@doc """
Updates a schema with given updates. Can also accept a keyword options list.
## Options
* `:repo` - A module that uses the Ecto.Repo Module.
* `:replica` - If you don't want to perform any reads against your Primary, you can specify a replica to read from.
## Examples
iex> user = create_user()
iex> {:ok, schema} = EctoSchemas.Actions.update(EctoSchemas.Accounts.User, user, first_name: user.first_name)
iex> schema.first_name === user.first_name
true
iex> user = create_user()
iex> {:ok, schema} = EctoSchemas.Actions.update(EctoSchemas.Accounts.User, 1, first_name: user.first_name, repo: MyApp.MyRepoModule.Repo, replica: MyApp.MyRepoModule.Repo.replica())
iex> schema.first_name === user.first_name
true
"""
@spec update(schema, id, updates) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
@spec update(schema, id, updates, opts) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
@spec update(schema, schema_data, updates) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
@spec update(schema, schema_data, updates, opts) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
def update(schema, schema_data, updates, opts \\ [])
def update(schema, schema_id, updates, opts) when is_integer(schema_id) or is_binary(schema_id) do
case get(schema, schema_id, opts) do
nil ->
{:error, Error.call(
:not_found,
"No item found with id: #{schema_id}",
%{
schema: schema,
schema_id: schema_id,
updates: updates
}
)}
schema_data -> update(schema, schema_data, updates, opts)
end
end
def update(schema, schema_data, updates, opts) when is_list(updates) do
update(schema, schema_data, Map.new(updates), opts)
end
def update(schema, schema_data, updates, opts) do
with {:ok, schema_data} <- repo!(opts).update(schema.changeset(schema_data, updates), opts) do
{:ok, schema_data}
end
end
@doc """
Deletes a schema
## Examples
iex> user = create_user()
iex> {:ok, schema} = EctoSchemas.Actions.delete(user)
iex> schema.first_name === user.first_name
true
"""
def delete(%_{} = schema_data) do
delete(schema_data, default_opts())
end
def delete(schema_data) when is_list(schema_data) do
delete(schema_data, default_opts())
end
@doc """
Similar to `delete/1` but can also accept a keyword options list.
## Options
* `:repo` - A module that uses the Ecto.Repo Module.
## Examples
iex> user = create_user()
iex> {:ok, schema} = EctoSchemas.Actions.delete(user, repo: MyApp.MyRepoModule.Repo)
iex> schema.first_name === user.first_name
true
"""
@spec delete(schema_data :: Ecto.Schema.t, opts) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
@spec delete(schema_data :: Ecto.Schema.t) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
def delete(%schema{} = schema_data, opts) do
case repo!(opts).delete(schema_data, opts) do
{:error, changeset} ->
{:error, Error.call(
:internal_server_error,
"Error deleting #{inspect(schema)}",
%{changeset: changeset, schema_data: schema_data}
)}
ok -> ok
end
end
def delete(schema_data, opts) when is_list(schema_data) do
schema_data |> Enum.map(&delete(&1, opts)) |> reduce_status_tuples
end
@spec delete(schema :: Ecto.Schema.t, id :: integer) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
def delete(schema, id) when is_atom(schema) and (is_binary(id) or is_integer(id)) do
delete(schema, id, default_opts())
end
@doc """
Deletes a schema. Can also accept a keyword options list.
## Options
* `:repo` - A module that uses the Ecto.Repo Module.
## Examples
iex> user = create_user()
iex> {:ok, schema} = EctoSchemas.Actions.delete(EctoSchemas.Accounts.User, user.id)
iex> schema.first_name === user.first_name
true
iex> user = create_user()
iex> {:ok, schema} = EctoSchemas.Actions.delete(EctoSchemas.Accounts.User, user.id)
iex> schema.first_name === user.first_name
true
"""
@spec delete(schema :: Ecto.Schema.t, id :: integer, opts) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
def delete(schema, id, opts) when is_atom(schema) and (is_integer(id) or is_binary(id)) do
with {:ok, schema_data} <- find(schema, %{id: id}, opts) do
repo!(opts).delete(schema_data, opts)
end
end
@spec stream(queryable :: query, params :: filter_params, opts) :: Enum.t
@spec stream(queryable :: query, params :: filter_params) :: Enum.t
@doc "Gets a collection of schemas from the database but allows for a filter"
def stream(query, params, opts \\ []) do
repo!(opts).stream(
CommonFilters.convert_params_to_filter(query, params),
opts
)
end
@spec aggregate(
queryable :: query,
params :: filter_params,
agg_opts :: aggregate_options,
field :: atom,
opts
) :: term
@spec aggregate(
queryable :: query,
params :: filter_params,
agg_opts :: aggregate_options,
field :: atom
) :: term
def aggregate(schema, params, aggregate, field, opts \\ []) do
repo!(opts).aggregate(
CommonFilters.convert_params_to_filter(schema, params),
aggregate,
field,
opts
)
end
@doc """
Accepts a list of schemas and attempts to find them in the DB. Any missing Schemas will be created.
Can also accept a keyword options list.
***Note: Relational filtering doesn't work on this function***
## Options
* `:repo` - A module that uses the Ecto.Repo Module.
* `:replica` - If you don't want to perform any reads against your Primary, you can specify a replica to read from.
## Examples
iex> {:ok, records} = EctoSchemas.Actions.find_or_create_many(EctoSchemas.Accounts.User, [%{name: "foo"}, %{name: "bar}])
iex> length(records) === 2
"""
@spec find_or_create_many(
Ecto.Schema.t(),
list(map),
opts
) :: {:ok, list(Ecto.Schema.t())} | {:error, list(Ecto.Changeset.t())}
def find_or_create_many(schema, param_list, opts) do
find_param_list = Enum.map(param_list, &Map.drop(&1, schema.__schema__(:associations)))
{create_params, found_results} = find_many(schema, find_param_list, opts)
schema
|> multi_insert(param_list, create_params)
|> repo!(opts).transaction()
|> case do
{:ok, created_map} -> {:ok, merge_found(created_map, found_results)}
error -> error
end
end
defp find_many(schema, param_list, opts) do
param_list
|> Enum.map(fn params ->
case find(schema, params, opts) do
{:ok, result} -> result
_ -> nil
end
end)
|> Enum.with_index()
|> Enum.split_with(fn {result, _index} -> is_nil(result) end)
end
defp multi_insert(schema, param_list, create_params) do
Enum.reduce(create_params, Ecto.Multi.new(), fn {nil, i}, multi ->
Ecto.Multi.insert(multi, i, fn _ ->
param_list
|> Enum.at(i)
|> create_changeset(schema)
end)
end)
end
defp create_changeset(params, schema) do
if function_exported?(schema, :create_changeset, 1) do
schema.create_changeset(params)
else
schema.changeset(struct(schema, %{}), params)
end
end
defp merge_found(created_map, found_results) do
created_map
|> Enum.map(fn {index, result} -> {result, index} end)
|> Kernel.++(found_results)
|> Enum.sort(&(elem(&1, 1) >= elem(&2, 1)))
|> Enum.map(&elem(&1, 0))
end
defp reduce_status_tuples(status_tuples) do
{status, res} =
Enum.reduce(status_tuples, {:ok, []}, fn
{:ok, _}, {:error, _} = e -> e
{:ok, record}, {:ok, acc} -> {:ok, [record | acc]}
{:error, error}, {:ok, _} -> {:error, [error]}
{:error, e}, {:error, error_acc} -> {:error, [e | error_acc]}
end)
{status, Enum.reverse(res)}
end
defp repo!(opts) do
with nil <- repo(opts) do
raise ArgumentError, message: "ecto shorts must be configured with a repo. For further guidence consult the docs. https://hexdocs.pm/ecto_shorts/EctoShorts.html#module-config"
end
end
# `replica!/1` will attempt to retrieve a repo from the replica key and default to
# returning the value under the repo: key if no replica is found. If no repos are configured
# an ArgumentError will be raised.
defp replica!(opts) do
with nil <- Keyword.get(opts, :replica, repo(opts)) do
raise ArgumentError, message: "ecto shorts must be configured with a repo. For further guidence consult the docs. https://hexdocs.pm/ecto_shorts/EctoShorts.html#module-config"
end
end
defp repo([]) do
Config.repo()
end
defp repo(opts) do
default_opts()
|> Keyword.merge(opts)
|> Keyword.get(:repo)
end
defp default_opts, do: [repo: Config.repo()]
end