A record keeps its files in its own columns: a map (jsonb) of each
file's key, URL, name, type, size, checksum, width and height. No table
of files, no join table: the record has its files, as it has its other
fields.
# The migration
alter table(:users) do
add :avatar, :map
add :photos, {:array, :map}, default: []
end
# The schema
schema "users" do
field :name, :string
field :avatar, Storehouse.Ecto.File
field :photos, {:array, Storehouse.Ecto.File}, default: []
endA file in a form
The upload is temporary until the form is saved (cache: true), and
Storehouse.Ecto.put_file/4 puts it in the changeset:
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(form: to_form(Accounts.change_user(socket.assigns.user)), avatar: nil)
|> Storehouse.Live.allow(:avatar,
accept: ~w(.png .jpg .jpeg .webp),
max_file_size: 5_000_000,
cache: true,
auto_upload: true,
progress: &handle_progress/3
)}
end
defp handle_progress(:avatar, _entry, socket) do
{socket, results} = Storehouse.Live.consume(socket, :avatar, accept: Storehouse.Check.images())
case results do
[{_entry, {:ok, file}} | _] -> {:noreply, assign(socket, :avatar, file)}
_other -> {:noreply, socket}
end
end
def handle_event("save", %{"user" => params}, socket) do
case Accounts.update_user(socket.assigns.user, params, socket.assigns.avatar) do
{:ok, user} -> {:noreply, assign(socket, user: user)}
{:error, changeset} -> {:noreply, assign(socket, form: to_form(changeset))}
end
end# Accounts
def update_user(user, attrs, avatar) do
changeset =
user
|> User.changeset(attrs)
|> then(&if avatar, do: Storehouse.Ecto.put_file(&1, :avatar, avatar, accept: Storehouse.Check.images(), max_bytes: 5_000_000), else: &1)
Storehouse.Ecto.update(Repo, changeset)
endput_file/4 checks the file against the field's own limits (an error
of the field, as any validation), and when the record is saved, makes it
permanent: a copy in the store, from its temporary key to its permanent
one, in the save's transaction. A copy that fails is an error of the
field, and the save rolls back.
Temporary, then permanent
A file uploaded for a form that is never saved stays under the temporary
prefix (cache/, Storehouse.cache_prefix/0). A lifecycle rule of the
bucket deletes it there after a day, with no job of yours:
{
"Rules": [
{"ID": "storehouse-cache", "Status": "Enabled", "Filter": {"Prefix": "cache/"}, "Expiration": {"Days": 1}}
]
}On AWS, in the bucket's management; on R2, its object lifecycle rules; on
LocalStack, awslocal s3api put-bucket-lifecycle-configuration. With
Storehouse.Local, delete the directory's old files with a job of yours
(cleanup is on the roadmap).
Making a file permanent can run again: a save that rolled back after it, tried again with the same upload, finds the permanent file there.
Replaced and deleted files
Storehouse.Ecto.update/3 saves, then deletes the files the record no
longer has (replaced, or removed with put_file(changeset, :avatar, nil)); Storehouse.Ecto.delete/3 deletes the record, then its files.
Deleted only once the database has the change: a save that fails leaves
every file where it was.
Inside a transaction of yours, they are not deleted (the transaction could still roll back): delete them after it commits.
{:ok, user} =
Repo.transaction(fn ->
{:ok, user} = Repo.update(changeset)
# ...
user
end)
Storehouse.Ecto.delete_files(Storehouse.Ecto.replaced(changeset, user))What a column takes
Storehouse.Ecto.File casts a Storehouse.File only, never a map of a
form's params: a file's key comes from Storehouse, never from a browser,
so that no one puts another's file in their record.