An Ecto type for files on a disk. The column holds the path, and loading a record returns
a Fil.Ref on the disk the field names:
schema "users" do
field :avatar, Fil.Ecto.Ref, disk: &MyApp.Storage.uploads/0
field :photos, {:array, Fil.Ecto.Ref}, disk: &MyApp.Storage.uploads/0, default: []
endThe refs that Fil.LiveView.consume_uploaded_entries/4 returns go into the changeset with
Ecto.Changeset.put_change/3:
{:ok, [avatar]} = Fil.LiveView.consume_uploaded_entries(socket, :avatar, disk)
user
|> Ecto.Changeset.change()
|> Ecto.Changeset.put_change(:avatar, avatar)
|> Repo.update()Only the path is stored, never the disk: a disk may hold credentials, and it's built from the config of the
environment the app runs in. The field's disk is resolved with Fil.Disk.resolve/1 each time a value is cast,
loaded or dumped, so a function can build it from runtime config.
Use a :text column for a single file (S3 keys can be 1,024 bytes long, :string is varchar(255) on Postgres
and MySQL):
add :avatar, :textNeeds Ecto 3.12, an optional dependency of Fil. The type does no I/O: it never checks that a file exists, and it
never deletes one (see Deleting replaced files).
Several files
{:array, Fil.Ecto.Ref} holds several files in order. The column depends on the database:
# Postgres: a text[] column
add :photos, {:array, :text}, null: false, default: []
# SQLite (ecto_sqlite3): JSON in a TEXT column
add :photos, {:array, :string}, null: false, default: []
# MySQL: a JSON column (the default needs MySQL 8.0.13)
add :photos, :json, null: false, default: fragment("(JSON_ARRAY())")A NULL column loads as nil, not [], hence null: false with a default. A nil in the list is kept, as with
{:array, Ecto.Enum}: [photo, nil] is stored and loaded as it is.
Casting
The field takes a Fil.Ref or nil, through Ecto.Changeset.cast/4 and Ecto.Changeset.put_change/3 alike.
Casting normalizes the path and drops :stat. A ref on another disk gives the changeset error "is on another
disk", and a path that leaves the disk root "is not a valid path".
Strings give "is invalid", because a string could come from a form, and a form could point the record at any file
on the disk. So leave the field out of cast/4 for user params, and put the refs you stored with put_change/3.
An empty string in the params would clear the field, too: cast/4 turns empty values into the default before the
type sees them.
put_change/3 doesn't cast, so dumping the value checks it again: a string or a ref on another disk in a
put_change/3 raises Ecto.ChangeError at the insert or update, instead of storing a path that means another
file. Ecto.Repo.insert_all/3 and Ecto.Repo.update_all/3 take refs too. insert_all/3 raises
Ecto.ChangeError for a string, and update_all/3 raises Ecto.Query.CastError, because its values are query
parameters.
A schemaless changeset gets the type from Ecto.ParameterizedType.init/2:
types = %{avatar: Ecto.ParameterizedType.init(Fil.Ecto.Ref, disk: &MyApp.Storage.uploads/0)}
changeset = Ecto.Changeset.cast({%{}, types}, %{avatar: avatar}, [:avatar])Two refs are equal when their paths are. Putting back the ref a record loaded, or one to the same path on the disk with other plugins, is no change.
Same storage
A ref fits the field when its disk is the same storage as the field's disk, as Fil.Disk.same_storage?/2 decides:
the same adapter and the same address, such as the same root, or the same bucket and prefix. Plugins, credentials
and other options that only change how the storage is reached don't count. So a ref from the disk with another
plugin attached fits, and so does one from a disk built before the S3 credentials were rotated. A ref on a local
disk with another root doesn't.
Loading
Each loaded value calls the disk function, once per ref (Ecto loads array elements one by one). A function that
builds an S3 disk with Fil.disk/1 validates its options each time, which adds about 10 µs per ref. For large
result sets, build the disk once at boot and return it:
# in MyApp.Application.start/2
:persistent_term.put({MyApp.Storage, :uploads}, Fil.disk(uploads_options))
# in MyApp.Storage
def uploads, do: :persistent_term.get({__MODULE__, :uploads})Loading builds the ref with Fil.ref/2, which normalizes the path and keeps one it can't normalize, such as
"../x.png", unchanged. An operation on that ref returns a Fil.InvalidRequestError. An empty string loads as the
disk root, ".".
After Ecto.Repo.insert/2, the struct holds the refs that were cast or put, with their own disks, and a reload
gives refs on the field's disk. Both name the same files.
Queries
A value compared with the field is cast and dumped with its type, so it has to be a ref:
from u in User, where: u.avatar == ^Fil.ref(disk, "avatars/1.png")where: u.avatar == ^"avatars/1.png" raises Ecto.Query.CastError. type(^path, :string) compares with a string
on Postgres and SQLite, but MySQL may refuse it with "Illegal mix of collations". select: u.avatar loads refs.
Deleting replaced files
The type can't delete anything, because it doesn't know whether the transaction commits. removed/2 returns the
refs a changeset drops, so you delete them after the commit:
changeset =
product
|> Ecto.Changeset.change()
|> Ecto.Changeset.put_change(:photos, kept_photos ++ new_photos)
removed = Fil.Ecto.Ref.removed(changeset, :photos)
with {:ok, product} <- Repo.update(changeset) do
Enum.each(removed, &Fil.rm/1)
{:ok, product}
endremoved/2 compares with changeset.data, the record as it was loaded, so when two requests edit the same record
at once, one can delete a file that the other saves again, unless you use Ecto.Changeset.optimistic_lock/3 or load
the record with a row lock in the transaction.
Clearing a single file works the same way, with put_change(:avatar, nil). Inside a transaction, delete after the
outermost one. If a delete fails, the file stays on the disk without a record, which is better than a record without
its file.
Deleting a record leaves its files: delete them after Ecto.Repo.delete/2, with
Enum.each(List.wrap(product.photos), &Fil.rm/1). When the changeset is invalid after a consume, the files the
consume stored belong to no record, so delete those refs.
Embedded schemas
A field in an embedded schema stores the path as well, so the JSON of an embed holds paths, not disks.
Rendering and JSON
Fil.Ref implements neither Phoenix.HTML.Safe nor Jason.Encoder, so {@user.avatar} in HEEx and
Jason.encode!(user.avatar) raise. A page needs a URL anyway, from Fil.url/2 or Fil.signed_url/3:
{:ok, avatar_url} = Fil.signed_url(user.avatar, expires_in: 3600)Options
:disk- Required. A capture of a named 0-arity function or{module, function, args}that returns aFil.Disk. A function is called each time the disk is used, so it can build the disk from runtime config. Ecto compiles field options into the schema module, so an anonymous function or a disk raises at compile time, and the arguments of an MFA end up in the compiled code:{MyApp.Storage, :disk, [:uploads]}is fine, credentials in the arguments are not. The function has to return a disk on the same storage each time (see Same storage).
Summary
Functions
Returns the refs that changeset drops from field, a Fil.Ecto.Ref or {:array, Fil.Ecto.Ref} field.
Functions
@spec removed(Ecto.Changeset.t(), atom()) :: [Fil.Ref.t()]
Returns the refs that changeset drops from field, a Fil.Ecto.Ref or {:array, Fil.Ecto.Ref} field.
These are the refs in the changeset's data whose path isn't among the paths of the change, in their order, each path once. Without a change the list is empty. Nothing is deleted; delete the refs after the commit (see Deleting replaced files).
iex> disk = Fil.disk(adapter: Fil.Adapter.Memory)
iex> type = Ecto.ParameterizedType.init(Fil.Ecto.Ref, disk: {Fil, :disk, [[adapter: Fil.Adapter.Memory]]})
iex> data = %{avatar: Fil.ref(disk, "avatars/old.png")}
iex> changeset = Ecto.Changeset.change({data, %{avatar: type}}, avatar: Fil.ref(disk, "avatars/new.png"))
iex> changeset
...> |> Fil.Ecto.Ref.removed(:avatar)
...> |> Enum.map(& &1.path)
["avatars/old.png"]It compares paths, not whole refs, so a kept ref with a :stat or on the disk with other plugins isn't removed.
nil in an array field is skipped, and so are strings in the data (a schemaless changeset may hold paths). An
array field changed to nil drops all its refs. Raises ArgumentError when field isn't one of the two types.
It compares with the changeset's data, whatever the record's state. For a changeset on a copy of another record,
such as %{product | id: nil}, the refs it returns still belong to that record, so don't delete them.