defmodule Bow.Ecto do
@moduledoc """
Integration of `Bow.Uploader` with `Ecto.Schema`
## Usage
# Add `use Bow.Ecto` to the uploader
defmodule MyApp.UserAvatarUploader do
use Bow.Uploader
use Bow.Ecto # <---- HERE
# file.scope will be the user struct
def store_dir(file) do
"users/\#{file.scope.id}/avatar"
end
end
# add avatar field to users table
defmodule MyApp.Repo.Migrations.AddAvatarToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add :avatar, :string
end
end
end
# use `MyApp.UserAvatarUploader.Type` as field type
defmodule MyApp.User do
schema "users" do
field :email, :string
field :avatar, MyApp.UserAvatarUploader.Type <-- HERE
end
def changeset(model \\ %__MODULE__{}, params) do
model
|> cast(params, [:email, :avatar])
|> Bow.Ecto.validate() # <---- HERE
end
end
# create user and save files
changeset = User.changeset(%User{}, params)
with {:ok, user} <- Repo.insert(changeset),
{:ok, user, _} <- Bow.Ecto.store(user) do
{:ok, user}
end
"""
@doc """
Customize incoming file.
This is the place to do custom file name transformation, since `filename/2`
is used both when uploading AND generating urls
Example - change file name to include timestamp
defmodule MyAvatarUploader do
use Bow.Uploader
use Bow.Ecto
def cast(file) do
ts = DateTime.utc_now |> DateTime.to_unix
Bow.set(file, :rootname, "avatar_\#{ts}")
end
end
"""
@callback cast(file :: Bow.t()) :: Bow.t()
defmacro __using__(_) do
uploader = __CALLER__.module
quote do
defmodule Type do
use Ecto.Type
def type, do: :string
def cast(%Plug.Upload{path: path, filename: name}) do
file = unquote(uploader).new(path: path, name: name)
{:ok, unquote(uploader).cast(file)}
end
def cast(%Bow{} = file) do
file = %{file | uploader: unquote(uploader)}
{:ok, unquote(uploader).cast(file)}
end
def load(name) do
{:ok, unquote(uploader).new(name: name)}
end
def dump(%{name: name}) do
{:ok, name}
end
def embed_as(_) do
:self
end
def equal?(left, right) do
left == right
end
end
@behaviour Bow.Ecto
def cast(file), do: file
defoverridable cast: 1
end
end
defmodule StoreError do
defexception message: nil, reason: nil
end
@typep model :: Ecto.Schema.t() | Ecto.Changeset.t()
@doc """
Validate changeset using uploader's `validate/1` function
Example
def changeset(model, params) do
model
|> cast(params, [:name, :avatar])
|> Bow.Ecto.validate()
end
"""
@spec validate(Ecto.Changeset.t()) :: Ecto.Changeset.t()
def validate(%Ecto.Changeset{} = changeset) do
changeset
|> extract_files()
|> filter_uploads()
|> Enum.reduce(changeset, &validate_upload/2)
end
@doc """
Store files assigned to uploaders in Ecto Schema or Changeset.
In order to understand how to properly use `store/1` function you need to read these few points:
- Ecto does not have callbacks (like `after_save` etc)
- `Ecto.Changeset.prepare_changes` is run *before* data is saved into database,
so when inserting a new record it will **not** have a primary key (id)
- Uploading during type casting is a bad idea
- You do want to use record primary key in storage directory definition
- You don't want to upload the same file multiple time, even if it hasn't changed
You need to pass inserted/updated record since the changeset lacks primary key.
When updating Bow.Ecto will upload only these files that were changed.
changeset = User.create_changeset(%User{}, params)
with {:ok, user} <- Repo.insert(changeset)
{:ok, _} <- Bow.Ecto.store(user) do # pass record here, not changeset
{:ok, user}
end
### Creating
user = User.changeset(params)
with {:ok, user} <- Repo.insert(user),
{:ok, user, results} <- Bow.Ecto.store(user) do
# ...
else
{:error, reason} -> # handle error
end
### Updating
user = User.changeset(user, params)
with {:ok, user} <- Repo.update(user),
{:ok, user, results} <- Bow.Ecto.store(user) do
# ...
else
{:error, reason} -> # handle error
end
There is also `store!/1` function that will raise error instead of returning `:ok/:error` tuple.
"""
@spec store(model) :: {:ok, model, map} | {:error, model, map}
def store(record) do
case do_store(record) do
{:ok, results} -> {:ok, record, results}
{:error, results} -> {:error, record, results}
end
end
@doc """
Same as `store/1` but raises an exception in case of upload error
### Creating
%User{}
|> User.changeset(params)
|> Repo.insert!()
|> Bow.Ecto.store!()
### Updating
user
|> User.changeset(params)
|> Repo.update!()
|> Bow.Ecto.store!()
"""
@spec store!(model | {:ok, model} | {:error, any}) :: {:ok, model} | {:error, any} | model
def store!({:error, _} = err), do: err
def store!({:ok, record}), do: {:ok, store!(record)}
def store!(record) do
case store(record) do
{:ok, record, _} -> record
{:error, _, reason} -> raise StoreError, message: inspect(reason), reason: reason
end
end
@doc """
Load file from storage
Example
user = Repo.get(...)
case Bow.Ecto.load(user, :avatar) do
{:ok, file} -> # file.path is populated with tmp path
{:error, reason} -> # handle load error
end
"""
@spec load(Ecto.Schema.t(), field :: atom) :: {:ok, Bow.t()} | {:error, any}
def load(record, field) do
record
|> Map.fetch!(field)
|> do_load(record)
end
@doc """
Delete record files from storage
Example
user = Repo.get(...)
user
|> Repo.delete!()
|> Bow.Ecto.delete()
"""
@spec delete(model) :: {:ok, model} | {:error, any}
def delete(record) do
with {:ok, _} <- do_delete(record) do
{:ok, record}
end
end
@doc """
Copy file from one record to another
Fields do not have be the same unless they use the same uploader
Example
user1 = Repo.get(1)
user2 = Repo.get(2)
Ecto.Bow.copy(user1, :avatar, user2)
"""
@spec copy(src :: Ecto.Schema.t(), field :: atom, dst :: Ecto.Schema.t()) ::
{:ok, map} | {:error, any}
def copy(src, src_field, dst) do
case Map.fetch!(src, src_field) do
nil ->
{:error, :missing}
file ->
src_file = Bow.set(file, :scope, src)
dst_file = Bow.set(src_file, :scope, dst)
Bow.copy(src_file, dst_file)
end
end
@doc """
Generate URL for record & field
"""
@spec url(Ecto.Schema.t(), atom) :: String.t() | nil
def url(record, field), do: url(record, field, [])
@spec url(Ecto.Schema.t(), atom, atom | list) :: String.t() | nil
def url(record, field, opts) when is_list(opts), do: url(record, field, :original, opts)
def url(record, field, version), do: url(record, field, version, [])
@spec url(Ecto.Schema.t(), atom, atom, list) :: String.t() | nil
def url(record, field, version, opts) do
record
|> Map.fetch!(field)
|> do_url(record, version, opts)
end
@doc """
Download remote files for given fields, i.e.
`params["remote_avatar_url"] = "http://example.com/some/file.png"`
Example
changeset
|> cast(params, [:name, :avatar])
|> Bow.Ecto.cast_uploads(params, [:avatar])
"""
@spec cast_uploads(any, map, list, Tesla.Client.t()) ::
Ecto.Changeset.t()
def cast_uploads(changeset, params, fields, client \\ %Tesla.Client{}) do
Ecto.Changeset.cast(changeset, download_params(params, fields, client), fields)
end
@spec download_params(map, list, Tesla.Client.t()) :: map
def download_params(params, fields, client \\ %Tesla.Client{}) do
Enum.reduce(fields, params, fn field, params ->
field = to_string(field)
case params["remote_#{field}_url"] do
nil ->
params
"" ->
params
url ->
case Bow.Download.download(client, url) do
{:ok, file} -> Map.put(params, field, file)
_ -> params
end
end
end)
end
defp do_load(nil, _), do: {:error, :missing}
defp do_load(file, record) do
file
|> Bow.set(:scope, record)
|> Bow.load()
end
defp do_url(nil, _, _, _), do: nil
defp do_url(file, record, version, opts) do
file
|> Bow.set(:scope, record)
|> Bow.url(version, opts)
end
defp validate_upload({field, file}, changeset) do
case file.uploader.validate(file) do
:ok ->
changeset
{:error, reason} ->
Ecto.Changeset.add_error(changeset, field, reason)
end
end
defp do_store(record) do
record
|> extract_files()
|> filter_uploads()
|> Enum.map(&store_upload(&1, record))
|> combine_results()
end
defp store_upload({field, file}, record), do: {field, Bow.store(%{file | scope: record})}
defp do_delete(record) do
record
|> extract_files()
|> Enum.map(&delete_upload(&1, record))
|> combine_results()
end
defp delete_upload({field, file}, record), do: {field, Bow.delete(%{file | scope: record})}
defp extract_files(%Ecto.Changeset{changes: changes}), do: filter_files(changes)
defp extract_files(record), do: record |> Map.from_struct() |> filter_files()
defp filter_files(fields), do: Enum.filter(fields, &file?/1)
defp filter_uploads(files), do: Enum.filter(files, &upload?/1)
defp file?({_, %Bow{}}), do: true
defp file?(_), do: false
defp upload?({_, %Bow{path: path}}), do: path != nil
# Similar to Bow.combine_results but flatten
# files results for easier pattern matching
@doc false
@spec combine_results(list) :: {:ok, map} | {:error, map}
def combine_results(results) do
Enum.reduce(results, {:ok, %{}}, fn
{key, {:error, res}}, {_, map} ->
{:error, Map.put(map, key, res)}
{key, {:ok, res}}, {ok, map} ->
{ok, Map.put(map, key, res)}
{key, res}, {ok, map} ->
{ok, Map.put(map, key, res)}
end)
end
end