RSSAmplifier

Dan Schultzer · Aug 6, 2023

When to use field :default in an Ecto schema

0
Sign in to vote or save

Dan Schultzer · Dan Schultzer

What does the :default option actually do when you set it on a field in an Ecto schema? This question came up when dealing with this scenario:

defmodule MyApp.Repo.Migrations.CreatePosts do
  use Ecto.Migration
  def up do
    create table(:posts) do
      add :category, :string
      timestamps()
    end
  end
end
defmodule MyApp.Posts.Post do
  use Ecto.Schema
  import Ecto.Changeset
  schema "posts" do
    field :category, :string, default: "all"
    timestamps()
  end
  def changeset(changeset, attrs) do
    changeset
    |> cast(attrs, [:category])
  end
end

A :default option is set on the schema field, but not in the migration column. This irked me. What does that mean? What is the guarantee here?

I needed to dig deeper to understand how :default is used. According to the Ecto docs for Ecto.Schema.field/3, :default sets the default value on the schema and the struct. Sounds like it’s just a defstruct call. Going into the bowels of Ecto I found out that was correct.

The field :default is used in the @ecto_struct_fields compile-time attribute. @ecto_struct_fields is a list of {key, default_or_assoc} tuples that’s used in a defstruct call like so:

defstruct Enum.reverse(@ecto_struct_fields)

This means that setting the :default option on a field is identical to this:

defmodule Post do
  defstruct category: "all"
end

What’s the difference between this and setting the :default option for Ecto.Migration.add/3?

When you set the :default option in the migration it will be used in the SQL as DEFAULT for the column definition like so:

CREATE TABLE posts
(
  'category' varchar(255) SET DEFAULT 'all'
);

Now let’s think of the consequences. What happens if we batch insert with Ecto.Repo.insert_all/3?

iex(1)> now = %{NaiveDateTime.utc_now() | microsecond: {0, 0}}
~N[2023-08-06 14:25:42]
iex(2)> MyApp.Repo.insert_all(MyApp.Posts.Post, [%{inserted_at: now, updated_at: now}])
{1, nil}
iex(3)> MyApp.Repo.all(MyApp.Posts.Post)
[
  %MyApp.Posts.Post{
    __meta__: #Ecto.Schema.Metadata<:loaded, "posts">,
    id: 1,
    category: nil,
    inserted_at: ~N[2023-08-06 14:25:42],
    updated_at: ~N[2023-08-06 14:25:42]
  }
]

Oh-uh, the category is nil! This makes sense as :default is only part of the struct, and the struct fields will be overridden with the values from the database when loaded. Thus :default is only used when we initialize a new struct.

One of the wonderful things about Ecto is how it makes developers let the database deal with what should be database concerns (for example Ecto.Changeset.unique_constraint/3). I think the same should be said for the :default option. Just let the database deal with it!

Let’s ask ourselves:

  • Should it be possible to have a nil value on a field? If not, set null: false in the migration.

  • Is there an expectation of a default value in the database? If yes, set default: "value" in the migration.

  • When should you use the :default option on the schema field?

    If you always require a value (ensured with a NOT NULL constraint in the database) and need some default value before running through the changeset. But even in that case, I would ask if there is a more explicit way to handle that, for example in the context function:

    defmodule MyApp.Posts do
      alias MyApp.{Posts.Post, Repo}
      @default_category "all"
      def create_post(attrs) do
        %Post{category: @default_category}
        |> Post.changeset(attrs)
        |> Repo.insert()
      end
    end

Embedded schema

Brian Underwood points out that one of the main reasons for :default existing could be database-less schemas:

@danschultzer Just read your post about Ecto defaults. I’ll bet that one of the main reasons why `default` exists is for when you’re using Ecto without a database since people often use Ecto for validating / transforming data from various sources without necessarily storing it.

A database-less schema is normally kept in memory using Ecto.Schema.embedded_schema/1:

defmodule MyApp.Posts.Post do
  use Ecto.Schema
  embedded_schema do
    field :category, :string, default: "all"
  end
end

Again, what does :default mean here? Should the source be trusted to not populate a nil value on a field that has the :default option?

iex(1)> data = %{"category" => nil}
%{"category" => nil}
iex(2)> Ecto.embedded_load(MyApp.Posts.Post, data, :json)
%MyApp.Posts.Post{id: nil, category: nil}

We have no guarantees as the :default option only helps us with initialization:

iex(3) Ecto.embedded_dump(%MyApp.Posts.Post{}, :json)
%{id: nil, category: "all"}
iex(4) Ecto.embedded_dump(%MyApp.Posts.Post{category: nil}, :json)
%{id: nil, category: nil}

It’s safer to transform the source data to ensure that fields don’t have a nil value if we depend on that:

iex(4) data = Map.update(data, "category", nil, & &1 || "all")
%{"category" => "all"}
iex(5) Ecto.embedded_load(MyApp.Posts.Post, data, :json)
%MyApp.Posts.Post{id: nil, category: "all"}

This is in the same vein as how we would enforce no nil value with a database constraint, though we have no control over storage here.

Hi, I'm Dan Schultzer, I write in this blog, work a lot in Elixir, maintain several open source projects, and help companies streamline their development process

Read the original on danschultzer.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.