Repository: elixir-ecto/ecto
Pull request #4472 (2024-08-05 00:59:18)

Change `:read_only` field option to `:writable`
This is based off our conversation in the insert_all update syntax PR. Our discussion brought to light that `read_only` is probably not granular enough.

This PR changes `read_only` to `writable` with allowed values `:always`, `:insert`, `:never`. `:insert` means it can only be created once but not modified once it's in the database. So it can't be used in `on_conflict` for upserts.

For reference this is the old PR: https://github.com/elixir-ecto/ecto/pull/4335. I used it to help me see where the changes needed to be.


Patch:
diff --git a/lib/ecto/query/planner.ex b/lib/ecto/query/planner.ex
index c0841f2c..0051e048 100644
--- a/lib/ecto/query/planner.ex
+++ b/lib/ecto/query/planner.ex
@@ -2197,7 +2197,7 @@ defmodule Ecto.Query.Planner do
         {{:source, {source, schema}, prefix || query.prefix, types}, fields}
 
       {{:ok, {_, fields}}, _} ->
-        {{:map, Enum.map(fields, &{&1, {:value, :any}})}, Enum.map(fields, &select_field(&1, ix, false))}
+        {{:map, Enum.map(fields, &{&1, {:value, :any}})}, Enum.map(fields, &select_field(&1, ix, :always))}
 
       {:error, {:fragment, _, _}} ->
         {{:value, :map}, [{:&, [], [ix]}]}
@@ -2207,7 +2207,7 @@ defmodule Ecto.Query.Planner do
 
         dumper =
           types
-          |> Enum.map(fn {field, type} -> {field, {field, type, false}} end)
+          |> Enum.map(fn {field, type} -> {field, {field, type, :always}} end)
           |> Enum.into(%{})
 
         {types, fields} = select_dump(fields, dumper, ix, drop)
@@ -2224,7 +2224,7 @@ defmodule Ecto.Query.Planner do
 
       {:error, %Ecto.SubQuery{select: select}} ->
         fields = subquery_source_fields(select)
-        {select, Enum.map(fields, &select_field(&1, ix, false))}
+        {select, Enum.map(fields, &select_field(&1, ix, :always))}
     end
   end
 
@@ -2233,16 +2233,16 @@ defmodule Ecto.Query.Planner do
     |> Enum.reverse()
     |> Enum.reduce({[], []}, fn
       field, {types, exprs} when is_atom(field) and not is_map_key(drop, field) ->
-        {source, type, read_only?} = Map.get(dumper, field, {field, :any, false})
-        {[{field, type} | types], [select_field(source, ix, read_only?) | exprs]}
+        {source, type, writable} = Map.get(dumper, field, {field, :any, :always})
+        {[{field, type} | types], [select_field(source, ix, writable) | exprs]}
 
       _field, acc ->
         acc
     end)
   end
 
-  defp select_field(field, ix, read_only?) do
-    {{:., [read_only: read_only?], [{:&, [], [ix]}, field]}, [], []}
+  defp select_field(field, ix, writable) do
+    {{:., [writable: writable], [{:&, [], [ix]}, field]}, [], []}
   end
 
   defp get_ix!({:&, _, [ix]} = expr, _kind, query) do
@@ -2609,8 +2609,8 @@ defmodule Ecto.Query.Planner do
             end
 
             case dumper do
-              %{^k => {_, _, false}} -> :ok
-              %{} -> error!(query, "cannot update unwritable field `#{k}`")
+              %{^k => {_, _, :always}} -> :ok
+              %{} -> error!(query, "cannot update non-updatable field `#{inspect(k)}`")
               nil -> :ok
             end
 
diff --git a/lib/ecto/repo/schema.ex b/lib/ecto/repo/schema.ex
index eb6362c5..e8316834 100644
--- a/lib/ecto/repo/schema.ex
+++ b/lib/ecto/repo/schema.ex
@@ -185,7 +185,7 @@ defmodule Ecto.Repo.Schema do
   defp init_mapper(schema, dumper, adapter, placeholder_map) do
     fn {field, value}, acc ->
       case dumper do
-        %{^field => {source, type, false}} ->
+        %{^field => {source, type, writable}} when writable != :never ->
           extract_value(source, value, type, placeholder_map, acc, fn val ->
             dump_field!(:insert_all, schema, field, type, val, adapter)
           end)
@@ -274,8 +274,8 @@ defmodule Ecto.Repo.Schema do
   end
 
   defp insert_all_select_dump!({{:., dot_meta, [{:&, _, [_]}, field]}, [], []}) do
-    if dot_meta[:read_only] == true do
-      raise ArgumentError, "cannot select unwritable field `#{field}` for insert_all"
+    if dot_meta[:writable] == :never do
+      raise ArgumentError, "cannot select unwritable field `#{inspect(field)}` for insert_all"
     else
       field
     end
@@ -283,8 +283,8 @@ defmodule Ecto.Repo.Schema do
 
   defp insert_all_select_dump!(field, dumper) when is_atom(field) do
     case dumper do
-      %{^field => {source, _, false}} -> source
-      %{} -> raise ArgumentError, "cannot select unwritable field `#{field}` for insert_all"
+      %{^field => {source, _, writable}} when writable != :never -> source
+      %{} -> raise ArgumentError, "cannot select unwritable field `#{inspect(field)}` for insert_all"
       nil -> field
     end
   end
@@ -366,7 +366,7 @@ defmodule Ecto.Repo.Schema do
     struct = struct_from_changeset!(:insert, changeset)
     schema = struct.__struct__
     dumper = schema.__schema__(:dump)
-    writable_fields = schema.__schema__(:writable_fields)
+    insertable_fields = schema.__schema__(:insertable_fields)
     assocs = schema.__schema__(:associations)
     embeds = schema.__schema__(:embeds)
 
@@ -383,7 +383,7 @@ defmodule Ecto.Repo.Schema do
     # On insert, we always merge the whole struct into the
     # changeset as changes, except the primary key if it is nil.
     changeset = put_repo_and_action(changeset, :insert, repo, tuplet)
-    changeset = Relation.surface_changes(changeset, struct, writable_fields ++ assocs)
+    changeset = Relation.surface_changes(changeset, struct, insertable_fields ++ assocs)
 
     wrap_in_transaction(adapter, adapter_meta, opts, changeset, assocs, embeds, prepare, fn ->
       assoc_opts = assoc_opts(assocs, opts)
@@ -402,7 +402,7 @@ defmodule Ecto.Repo.Schema do
         {changes, cast_extra, dump_extra, return_types, return_sources} =
           autogenerate_id(autogen_id, changes, return_types, return_sources, adapter)
 
-        changes = Map.take(changes, writable_fields)
+        changes = Map.take(changes, insertable_fields)
         autogen = autogenerate_changes(schema, :insert, changes)
 
         dump_changes =
@@ -458,7 +458,7 @@ defmodule Ecto.Repo.Schema do
     struct = struct_from_changeset!(:update, changeset)
     schema = struct.__struct__
     dumper = schema.__schema__(:dump)
-    writable_fields = schema.__schema__(:writable_fields)
+    updatable_fields = schema.__schema__(:updatable_fields)
     assocs = schema.__schema__(:associations)
     embeds = schema.__schema__(:embeds)
 
@@ -487,7 +487,7 @@ defmodule Ecto.Repo.Schema do
         if changeset.valid? do
           embeds = Ecto.Embedded.prepare(changeset, embeds, adapter, :update)
 
-          changes = changeset.changes |> Map.merge(embeds) |> Map.take(writable_fields)
+          changes = changeset.changes |> Map.merge(embeds) |> Map.take(updatable_fields)
           autogen = autogenerate_changes(schema, :update, changes)
           dump_changes = dump_changes!(:update, changes, autogen, schema, [], dumper, adapter)
 
@@ -669,7 +669,7 @@ defmodule Ecto.Repo.Schema do
   end
   defp fields_to_sources(fields, dumper) do
     Enum.reduce(fields, {[], []}, fn field, {types, sources} ->
-      {source, type, _read_only?} = Map.fetch!(dumper, field)
+      {source, type, _writable} = Map.fetch!(dumper, field)
       {[{field, type} | types], [source | sources]}
     end)
   end
@@ -786,12 +786,12 @@ defmodule Ecto.Repo.Schema do
   defp replace_fields!(dumper, fields) do
     Enum.map(fields, fn field ->
       case dumper do
-        %{^field => {source, _type, false}} ->
+        %{^field => {source, _type, :always}} ->
           source
 
         _ ->
           raise ArgumentError,
-                "cannot replace unwritable field `#{inspect(field)}` in :on_conflict option"
+                "cannot replace non-updatable field `#{inspect(field)}` in :on_conflict option"
       end
     end)
   end
@@ -801,7 +801,7 @@ defmodule Ecto.Repo.Schema do
   end
 
   defp replace_all_fields!(_kind, schema, to_remove) do
-    Enum.map(schema.__schema__(:writable_fields) -- to_remove, &field_source!(schema, &1))
+    Enum.map(schema.__schema__(:updatable_fields) -- to_remove, &field_source!(schema, &1))
   end
 
   defp field_source!(nil, field) do
@@ -1117,7 +1117,7 @@ defmodule Ecto.Repo.Schema do
 
   defp dump_fields!(action, schema, kw, dumper, adapter) do
     for {field, value} <- kw do
-      {alias, type, _read_only?} = Map.fetch!(dumper, field)
+      {alias, type, _writable} = Map.fetch!(dumper, field)
       {alias, dump_field!(action, schema, field, type, value, adapter)}
     end
   end
diff --git a/lib/ecto/schema.ex b/lib/ecto/schema.ex
index f00695c5..5d4122d3 100644
--- a/lib/ecto/schema.ex
+++ b/lib/ecto/schema.ex
@@ -507,7 +507,6 @@ defmodule Ecto.Schema do
       Module.register_attribute(__MODULE__, :ecto_autogenerate, accumulate: true)
       Module.register_attribute(__MODULE__, :ecto_autoupdate, accumulate: true)
       Module.register_attribute(__MODULE__, :ecto_redact_fields, accumulate: true)
-      Module.register_attribute(__MODULE__, :ecto_read_only, accumulate: true)
       Module.put_attribute(__MODULE__, :ecto_derive_inspect_for_redacted_fields, true)
       Module.put_attribute(__MODULE__, :ecto_autogenerate_id, nil)
     end
@@ -529,7 +528,7 @@ defmodule Ecto.Schema do
     :where,
     :references,
     :skip_default_validation,
-    :read_only
+    :writable
   ]
 
   @doc """
@@ -645,7 +644,6 @@ defmodule Ecto.Schema do
         assocs = @ecto_assocs |> Enum.reverse()
         embeds = @ecto_embeds |> Enum.reverse()
         redacted_fields = @ecto_redact_fields
-        read_only = @ecto_read_only |> Enum.reverse()
         loaded = Ecto.Schema.__loaded__(__MODULE__, @ecto_struct_fields)
 
         if redacted_fields != [] and not List.keymember?(@derive, Inspect, 0) and
@@ -672,7 +670,8 @@ defmodule Ecto.Schema do
         def __schema__(:loaded), do: unquote(Macro.escape(loaded))
         def __schema__(:redact_fields), do: unquote(redacted_fields)
         def __schema__(:virtual_fields), do: unquote(Enum.map(virtual_fields, &elem(&1, 0)))
-        def __schema__(:writable_fields), do: unquote(for {name, false} <- read_only, do: name)
+        def __schema__(:updatable_fields), do: unquote(for {name, {_, :always}}  <- fields, do: name)
+        def __schema__(:insertable_fields), do: unquote(for {name, {_, writable}} when writable != :never <- fields, do: name)
 
         def __schema__(:autogenerate_fields),
           do: unquote(Enum.flat_map(autogenerate, &elem(&1, 0)))
@@ -694,8 +693,7 @@ defmodule Ecto.Schema do
                 field_sources,
                 assocs,
                 embeds,
-                virtual_fields,
-                read_only
+                virtual_fields
               ),
             {args, body} <- clauses do
           def __schema__(unquote_splicing(args)), do: unquote(body)
@@ -773,7 +771,11 @@ defmodule Ecto.Schema do
     * `:skip_default_validation` - When true, it will skip the type validation
       step at compile time.
 
-    * `:read_only` - If `true`, the schema field cannot be written to. Defaults to `false`.
+    * `:writable` - Defines when a field is allowed to be modified. Must be one of
+      `:always`, `:insert`, or `:never`. If set to `:always`, the field can be modified
+      by any repo operation. If set to `:insert`, the field can be inserted but cannot
+      be further modified, even in an upsert. If set to `:never`, the field becomes
+      read only. Defaults to `:always`.
 
   """
   defmacro field(name, type \\ :string, opts \\ []) do
@@ -2061,7 +2063,7 @@ defmodule Ecto.Schema do
   defp define_field(mod, name, type, opts) do
     virtual? = opts[:virtual] || false
     pk? = opts[:primary_key] || false
-    read_only? = opts[:read_only] || false
+    writable = opts[:writable] || :always
     put_struct_field(mod, name, Keyword.get(opts, :default))
 
     if Keyword.get(opts, :redact, false) do
@@ -2101,8 +2103,8 @@ defmodule Ecto.Schema do
         raise ArgumentError, "cannot mark the same field as autogenerate and read_after_writes"
       end
 
-      if read_only? && gen do
-        raise ArgumentError, "cannot mark the same field as autogenerate and read only"
+      if writable != :always && gen do
+        raise ArgumentError, "autogenerated fields must always be writable"
       end
 
       if pk? do
@@ -2113,8 +2115,7 @@ defmodule Ecto.Schema do
         Module.put_attribute(mod, :ecto_query_fields, {name, type})
       end
 
-      Module.put_attribute(mod, :ecto_read_only, {name, read_only?})
-      Module.put_attribute(mod, :ecto_fields, {name, type})
+      Module.put_attribute(mod, :ecto_fields, {name, {type, writable}})
     end
   end
 
@@ -2321,9 +2322,9 @@ defmodule Ecto.Schema do
   end
 
   @doc false
-  def __schema__(fields, field_sources, assocs, embeds, virtual_fields, read_only) do
+  def __schema__(fields, field_sources, assocs, embeds, virtual_fields) do
     load =
-      for {name, type} <- fields do
+      for {name, {type, _writable}} <- fields do
         if alias = field_sources[name] do
           {name, {:source, alias, type}}
         else
@@ -2332,17 +2333,17 @@ defmodule Ecto.Schema do
       end
 
     dump =
-      for {name, type} <- fields do
-        {name, {field_sources[name] || name, type, read_only[name]}}
+      for {name, {type, writable}} <- fields do
+        {name, {field_sources[name] || name, type, writable}}
       end
 
     field_sources_quoted =
-      for {name, _type} <- fields do
+      for {name, {_type, _writable}} <- fields do
         {[:field_source, name], field_sources[name] || name}
       end
 
     types_quoted =
-      for {name, type} <- fields do
+      for {name, {type, _writable}} <- fields do
         {[:type, name], Macro.escape(type)}
       end
 
diff --git a/lib/ecto/schema/loader.ex b/lib/ecto/schema/loader.ex
index 7015b3c3..8ee64714 100644
--- a/lib/ecto/schema/loader.ex
+++ b/lib/ecto/schema/loader.ex
@@ -91,7 +91,7 @@ defmodule Ecto.Schema.Loader do
   Dumps the given data.
   """
   def safe_dump(struct, types, dumper) do
-    Enum.reduce(types, %{}, fn {field, {source, type, _read_only?}}, acc ->
+    Enum.reduce(types, %{}, fn {field, {source, type, _writable}}, acc ->
       value = Map.get(struct, field)
 
       case dumper.(type, value) do


Test patch:
diff --git a/integration_test/cases/repo.exs b/integration_test/cases/repo.exs
index fd33a3d2..667db70a 100644
--- a/integration_test/cases/repo.exs
+++ b/integration_test/cases/repo.exs
@@ -1312,112 +1312,6 @@ defmodule Ecto.Integration.RepoTest do
     assert TestRepo.get(Post, id).temp == "temp"
   end
 
-  describe "read only fields" do
-    test "select with read only field" do
-      {1, _} = TestRepo.insert_all("posts", [%{title: "1", read_only: "readonly"}])
-      query = from p in Post, where: p.read_only == ^"readonly", select: p.read_only
-
-      assert "readonly" == TestRepo.one(query)
-      assert %{read_only: "readonly"} = TestRepo.one(Post)
-    end
-
-    test "update with read only field" do
-      %Post{id: id} = post = TestRepo.insert!(%Post{title: "1"})
-      cs = Ecto.Changeset.change(post, %{title: "2", read_only: "nope"})
-      TestRepo.update!(cs)
-      assert is_nil(TestRepo.get(Post, id).read_only)
-    end
-
-    @tag :returning
-    test "update with read only field and returning" do
-      post = TestRepo.insert!(%Post{title: "1"})
-      cs = Ecto.Changeset.change(post, %{title: "2", read_only: "nope"})
-      updated_post = TestRepo.update!(cs, returning: true)
-      assert is_nil(updated_post.read_only)
-    end
-
-    test "update_all with read only field" do
-      TestRepo.insert!(%Post{title: "1"})
-      update_query = from p in Post, where: p.title == "1", update: [set: [read_only: "nope"]]
-
-      assert_raise Ecto.QueryError,  ~r/cannot update unwritable field `read_only` in query/, fn ->
-        TestRepo.update_all(update_query, [])
-      end
-    end
-
-    test "insert with read only field" do
-      %Post{id: id} = TestRepo.insert!(%Post{title: "1", read_only: "nope"})
-      assert is_nil(TestRepo.get(Post, id).read_only)
-    end
-
-    @tag :returning
-    test "insert with read only field and returning" do
-      post = TestRepo.insert!(%Post{title: "1", read_only: "nope"}, returning: true)
-      assert is_nil(post.read_only)
-    end
-
-    test "insert with read only field and conflict query" do
-      on_conflict = from Post, update: [set: [read_only: "nope"]]
-
-      assert_raise Ecto.QueryError,  ~r/cannot update unwritable field `read_only` in query/, fn ->
-        TestRepo.insert!(%Post{title: "1"}, on_conflict: on_conflict)
-      end
-
-      assert_raise Ecto.QueryError,  ~r/cannot update unwritable field `read_only` in query/, fn ->
-        TestRepo.insert!(%Post{title: "1"}, on_conflict: [set: [read_only: "nope"]])
-      end
-    end
-
-    test "insert with read only field and conflict replace" do
-      msg = "cannot replace unwritable field `:read_only` in :on_conflict option"
-
-      assert_raise ArgumentError,  msg, fn ->
-        TestRepo.insert!(%Post{title: "1"}, on_conflict: {:replace, [:read_only]})
-      end
-    end
-
-    @tag :with_conflict_target
-    @tag :upsert
-    test "insert with read only field and conflict replace_all" do
-      uuid = Ecto.UUID.generate()
-      TestRepo.insert!(%Post{uuid: uuid, title: "1"})
-      %Post{id: id} = TestRepo.insert!(%Post{uuid: uuid, title: "2"}, conflict_target: [:uuid], on_conflict: :replace_all)
-      assert %{title: "2", read_only: nil} = TestRepo.get(Post, id)
-    end
-
-    @tag :returning
-    @tag :with_conflict_target
-    @tag :upsert
-    test "insert with read only field and conflict replace_all and returning" do
-      uuid = Ecto.UUID.generate()
-      TestRepo.insert!(%Post{uuid: uuid, title: "1"})
-
-      post =
-        TestRepo.insert!(%Post{uuid: uuid, title: "2", read_only: "nope"},
-          conflict_target: [:uuid],
-          on_conflict: :replace_all,
-          returning: true
-        )
-
-      assert %{title: "2", read_only: nil} = post
-    end
-
-    test "insert_all with read only field" do
-      msg = ~r/Unwritable fields, such as virtual and read only fields are not supported./
-
-      assert_raise ArgumentError, msg, fn ->
-        TestRepo.insert_all(Post, [%{title: "1", read_only: "nope"}])
-      end
-
-      msg = "cannot select unwritable field `read_only` for insert_all"
-
-      assert_raise ArgumentError, msg, fn ->
-        query = from p in Post, select: %{read_only: p.read_only}
-        TestRepo.insert_all(Post, query)
-      end
-    end
-  end
-
   ## Query syntax
 
   defmodule Foo do
diff --git a/integration_test/support/schemas.exs b/integration_test/support/schemas.exs
index 01c50e7a..62b96d4a 100644
--- a/integration_test/support/schemas.exs
+++ b/integration_test/support/schemas.exs
@@ -46,7 +46,6 @@ defmodule Ecto.Integration.Post do
     field :links, {:map, :string}
     field :intensities, {:map, :float}
     field :posted, :date
-    field :read_only, :string, read_only: true
     has_many :comments, Ecto.Integration.Comment, on_delete: :delete_all, on_replace: :delete
     has_many :force_comments, Ecto.Integration.Comment, on_replace: :delete_if_exists
     has_many :ordered_comments, Ecto.Integration.Comment, preload_order: [:text]
diff --git a/test/ecto/changeset_test.exs b/test/ecto/changeset_test.exs
index 5969ceb6..70abd7a3 100644
--- a/test/ecto/changeset_test.exs
+++ b/test/ecto/changeset_test.exs
@@ -97,7 +97,7 @@ defmodule Ecto.ChangesetTest do
       field :topics, {:array, :string}
       field :seo_metadata, :map
       field :virtual, :string, virtual: true
-      field :read_only, :string, read_only: true
+      field :unwritable, :string, writable: :never
       field :published_at, :naive_datetime
       field :source, :map
       field :permalink, :string, source: :url
@@ -124,7 +124,7 @@ defmodule Ecto.ChangesetTest do
     cast(
       schema,
       params,
-      ~w(id token title author_email body upvotes decimal color topics seo_metadata virtual read_only)a
+      ~w(id token title author_email body upvotes decimal color topics seo_metadata virtual unwritable)a
     )
   end
 
@@ -1173,10 +1173,10 @@ defmodule Ecto.ChangesetTest do
     assert changeset.valid?
     assert changeset.errors == []
 
-    # When read only
+    # When writable is set to non-default value
     changeset =
-      changeset(%{"read_only" => "hello"})
-      |> validate_change(:read_only, fn :read_only, "hello" -> [] end)
+      changeset(%{"unwritable" => "hello"})
+      |> validate_change(:unwritable, fn :unwritable, "hello" -> [] end)
 
     assert changeset.valid?
     assert changeset.errors == []
@@ -3586,7 +3586,7 @@ defmodule Ecto.ChangesetTest do
       field :username, :string
       field :display_name, :string, redact: false
       field :virtual_pass, :string, redact: true, virtual: true
-      field :read_only_pass, :string, redact: true, read_only: true
+      field :unwritable_pass, :string, redact: true, writable: :never
     end
   end
 
@@ -3645,9 +3645,9 @@ defmodule Ecto.ChangesetTest do
       assert inspect(changeset) =~ "**redacted**"
     end
 
-    test "redacts read only fields marked redact: true" do
+    test "redacts unwritable fields marked redact: true" do
       changeset =
-        Ecto.Changeset.cast(%RedactedSchema{}, %{read_only_pass: "hunter2"}, [:read_only_pass])
+        Ecto.Changeset.cast(%RedactedSchema{}, %{unwritable_pass: "hunter2"}, [:unwritable_pass])
 
       refute inspect(changeset) =~ "hunter2"
       assert inspect(changeset) =~ "**redacted**"
diff --git a/test/ecto/query/planner_test.exs b/test/ecto/query/planner_test.exs
index f0173c77..7099932a 100644
--- a/test/ecto/query/planner_test.exs
+++ b/test/ecto/query/planner_test.exs
@@ -166,7 +166,7 @@ defmodule Ecto.Query.PlannerTest do
 
   defp select_fields(fields, ix) do
     for field <- fields do
-      {{:., [read_only: false], [{:&, [], [ix]}, field]}, [], []}
+      {{:., [writable: :always], [{:&, [], [ix]}, field]}, [], []}
     end
   end
 
@@ -1866,7 +1866,7 @@ defmodule Ecto.Query.PlannerTest do
     start_param_ix = 0
     native_types = %{bid: :uuid, num: :integer}
     types_kw = Enum.map(types, fn {field, _} -> {field, native_types[field]} end)
-    field_ast = Enum.map(types, fn {field, _} -> {{:., [read_only: false], [{:&, [], [0]}, field]}, [], []} end)
+    field_ast = Enum.map(types, fn {field, _} -> {{:., [writable: :always], [{:&, [], [0]}, field]}, [], []} end)
 
     assert q.from.source == {:values, [], [types_kw, start_param_ix, length(values)]}
     assert q.select.fields == field_ast
@@ -1884,7 +1884,7 @@ defmodule Ecto.Query.PlannerTest do
     start_param_ix = 1
     native_types = %{bid: :uuid, num: :integer}
     types_kw = Enum.map(types, fn {field, _} -> {field, native_types[field]} end)
-    field_ast = Enum.map(types, fn {field, _} -> {{:., [read_only: false], [{:&, [], [1]}, field]}, [], []} end)
+    field_ast = Enum.map(types, fn {field, _} -> {{:., [writable: :always], [{:&, [], [1]}, field]}, [], []} end)
     [join] = q.joins
 
     assert join.source == {:values, [], [types_kw, start_param_ix, length(values)]}
@@ -2675,8 +2675,8 @@ defmodule Ecto.Query.PlannerTest do
       query = "schema" |> select([s], %{x1: selected_as(s.x, :integer), x2: s.x})
       %{select: select} = from(q in subquery(query)) |> normalize()
 
-      field1 = {{:., [read_only: false], [{:&, [], [0]}, :integer]}, [], []}
-      field2 = {{:., [read_only: false], [{:&, [], [0]}, :x2]}, [], []}
+      field1 = {{:., [writable: :always], [{:&, [], [0]}, :integer]}, [], []}
+      field2 = {{:., [writable: :always], [{:&, [], [0]}, :x2]}, [], []}
       assert [^field1, ^field2] = select.fields
     end
 
@@ -2685,8 +2685,8 @@ defmodule Ecto.Query.PlannerTest do
       s2 = from s in subquery(s1), select: %{y1: selected_as(s.integer, :integer2), y2: s.x2}
       %{select: select} = from(q in subquery(s2)) |> normalize()
 
-      field1 = {{:., [read_only: false], [{:&, [], [0]}, :integer2]}, [], []}
-      field2 = {{:., [read_only: false], [{:&, [], [0]}, :y2]}, [], []}
+      field1 = {{:., [writable: :always], [{:&, [], [0]}, :integer2]}, [], []}
+      field2 = {{:., [writable: :always], [{:&, [], [0]}, :y2]}, [], []}
       assert [^field1, ^field2] = select.fields
     end
 
diff --git a/test/ecto/query/subquery_test.exs b/test/ecto/query/subquery_test.exs
index 80ac26f5..8842f78f 100644
--- a/test/ecto/query/subquery_test.exs
+++ b/test/ecto/query/subquery_test.exs
@@ -56,7 +56,7 @@ defmodule Ecto.Query.SubqueryTest do
 
   defp select_fields(fields, ix) do
     for field <- fields do
-      {{:., [read_only: false], [{:&, [], [ix]}, field]}, [], []}
+      {{:., [writable: :always], [{:&, [], [ix]}, field]}, [], []}
     end
   end
 
diff --git a/test/ecto/repo_test.exs b/test/ecto/repo_test.exs
index 0ad992ef..63ee040a 100644
--- a/test/ecto/repo_test.exs
+++ b/test/ecto/repo_test.exs
@@ -160,11 +160,13 @@ defmodule Ecto.RepoTest do
     end
   end
 
-  defmodule MySchemaReadOnly do
+  defmodule MySchemaWritable do
     use Ecto.Schema
 
     schema "my_schema" do
-      field :x, :string, read_only: true
+      field :never, :integer, writable: :never
+      field :always, :integer, writable: :always
+      field :insert, :integer, writable: :insert
     end
   end
 
@@ -764,7 +766,7 @@ defmodule Ecto.RepoTest do
       msg = ~r"cannot select unwritable field"
 
       assert_raise ArgumentError, msg, fn ->
-        TestRepo.insert_all(MySchemaReadOnly, from(r in MySchemaReadOnly, select: r))
+        TestRepo.insert_all(MySchemaWritable, from(w in MySchemaWritable, select: w))
       end
     end
 
@@ -1746,7 +1748,7 @@ defmodule Ecto.RepoTest do
     end
 
     test "raises on non-existent fields on replace" do
-      msg = "cannot replace unwritable field `:unknown` in :on_conflict option"
+      msg = "cannot replace non-updatable field `:unknown` in :on_conflict option"
 
       assert_raise ArgumentError, msg, fn ->
         TestRepo.insert(
@@ -2090,9 +2092,137 @@ defmodule Ecto.RepoTest do
     end
   end
 
+  describe "writable field option" do
+    test "select" do
+      TestRepo.all(from(w in MySchemaWritable, select: w))
+      assert_receive {:all, query}
+
+      assert query.select.fields == [
+               {{:., [writable: :always], [{:&, [], [0]}, :id]}, [], []},
+               {{:., [writable: :never], [{:&, [], [0]}, :never]}, [], []},
+               {{:., [writable: :always], [{:&, [], [0]}, :always]}, [], []},
+               {{:., [writable: :insert], [{:&, [], [0]}, :insert]}, [], []}
+             ]
+    end
+
+
+    test "update only saves changes for writable: :always" do
+      %MySchemaWritable{id: 1}
+      |> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12})
+      |> TestRepo.update()
+
+      assert_received {:update, %{changes: [always: 10]}}
+    end
+
+    test "update is a no-op when updatable fields are not changed" do
+      %MySchemaWritable{id: 1}
+      |> Ecto.Changeset.change(%{never: "can't update", insert: "can't update either"})
+      |> TestRepo.update()
+
+      refute_received {:update, _meta}
+    end
+
+    test "update with returning" do
+      %MySchemaWritable{id: 1}
+      |> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12})
+      |> TestRepo.update(returning: true)
+
+      assert_received {:update, %{returning: returning}}
+      assert Enum.sort(returning) == [:always, :id, :insert, :never]
+    end
+
+    test "update_all raises if non-updatable field is set" do
+      update_query = from w in MySchemaWritable, update: [set: [never: 10]]
+
+      assert_raise Ecto.QueryError, ~r/cannot update non-updatable field `:never` in query/, fn ->
+        TestRepo.update_all(update_query, [])
+      end
+
+      update_query = from w in MySchemaWritable, update: [set: [insert: 10]]
+
+      assert_raise Ecto.QueryError,  ~r/cannot update non-updatable field `:insert` in query/, fn ->
+        TestRepo.update_all(update_query, [])
+      end
+    end
+
+    test "insert only saves changes for writable: :always/:insert" do
+      %MySchemaWritable{id: 1}
+      |> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12})
+      |> TestRepo.insert()
+
+      assert_received {:insert, %{fields: inserted_fields}}
+      assert Enum.sort(inserted_fields) == [always: 10, id: 1, insert: 12]
+    end
+
+    test "insert with returning" do
+      %MySchemaWritable{id: 1}
+      |> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12})
+      |> TestRepo.insert(returning: true)
+
+      assert_received {:insert, %{fields: inserted_fields, returning: returning}}
+      assert Enum.sort(inserted_fields) == [always: 10, id: 1, insert: 12]
+      assert Enum.sort(returning) == [:always, :id, :insert, :never]
+    end
+
+    test "insert with on_conflict" do
+      # conflict query
+      on_conflict = from w in MySchemaWritable, update: [set: [insert: 10]]
+
+      assert_raise Ecto.QueryError, ~r/cannot update non-updatable field `:insert` in query/, fn ->
+        TestRepo.insert(%MySchemaWritable{}, on_conflict: on_conflict)
+      end
+
+      # conflict keyword
+      assert_raise Ecto.QueryError, ~r/cannot update non-updatable field `:never` in query/, fn ->
+        TestRepo.insert(%MySchemaWritable{}, on_conflict: [set: [never: 10]])
+      end
+
+      # conflict replace
+      assert_raise ArgumentError, ~r/cannot replace non-updatable field `:never` in :on_conflict option/, fn ->
+        TestRepo.insert(%MySchemaWritable{}, on_conflict: {:replace, [:always, :never]})
+      end
+    end
+
+    test "insert with on_conflict = replace_all and returning" do
+      TestRepo.insert!(%MySchemaWritable{always: 1, never: 2, insert: 3},
+        on_conflict: :replace_all,
+        returning: true
+      )
+
+      assert_received {:insert, %{fields: inserted_fields, returning: returning}}
+      assert Enum.sort(inserted_fields) == [always: 1, insert: 3]
+      assert Enum.sort(returning) == [:always, :id, :insert, :never]
+    end
+
+    test "insert_all" do
+      # selecting maps
+      msg = ~r/Unwritable fields, such as virtual and read only fields are not supported./
+
+      assert_raise ArgumentError, msg, fn ->
+        TestRepo.insert_all(MySchemaWritable, [%{always: 1, insert: 3, never: 2}])
+      end
+
+      # selecting individual fields
+      msg = "cannot select unwritable field `:never` for insert_all"
+
+      assert_raise ArgumentError, msg, fn ->
+        query = from w in MySchemaWritable, select: %{always: w.always, insert: w.insert, never: w.insert}
+        TestRepo.insert_all(MySchemaWritable, query)
+      end
+
+      # selecting sources
+      msg = "cannot select unwritable field `:never` for insert_all"
+
+      assert_raise ArgumentError, msg, fn ->
+        query = from w in MySchemaWritable, select: w
+        TestRepo.insert_all(MySchemaWritable, query)
+      end
+    end
+  end
+
   defp select_fields(fields, ix) do
     for field <- fields do
-      {{:., [read_only: false], [{:&, [], [ix]}, field]}, [], []}
+      {{:., [writable: :always], [{:&, [], [ix]}, field]}, [], []}
     end
   end
 end
diff --git a/test/ecto/schema_test.exs b/test/ecto/schema_test.exs
index 661dc670..6e9fd99c 100644
--- a/test/ecto/schema_test.exs
+++ b/test/ecto/schema_test.exs
@@ -15,7 +15,8 @@ defmodule Ecto.SchemaTest do
       field :array, {:array, :string}
       field :uuid, Ecto.UUID, autogenerate: true
       field :no_query_load, :string, load_in_query: false
-      field :read_only, :string, read_only: true
+      field :unwritable, :string, writable: :never
+      field :non_updatable, :string, writable: :insert
       belongs_to :comment, Comment
       belongs_to :permalink, Permalink, define_field: false
     end
@@ -26,15 +27,18 @@ defmodule Ecto.SchemaTest do
     assert Schema.__schema__(:prefix) == nil
 
     assert Schema.__schema__(:fields) ==
-             [:id, :name, :email, :password, :count, :array, :uuid, :no_query_load, :read_only, :comment_id]
+             [:id, :name, :email, :password, :count, :array, :uuid, :no_query_load, :unwritable, :non_updatable, :comment_id]
 
-    assert Schema.__schema__(:writable_fields) ==
+    assert Schema.__schema__(:insertable_fields) ==
+             [:id, :name, :email, :password, :count, :array, :uuid, :no_query_load, :non_updatable, :comment_id]
+
+    assert Schema.__schema__(:updatable_fields) ==
              [:id, :name, :email, :password, :count, :array, :uuid, :no_query_load, :comment_id]
 
     assert Schema.__schema__(:virtual_fields) == [:temp]
 
     assert Schema.__schema__(:query_fields) ==
-             [:id, :name, :email, :password, :count, :array, :uuid, :read_only, :comment_id]
+             [:id, :name, :email, :password, :count, :array, :uuid, :unwritable, :non_updatable, :comment_id]
 
     assert Schema.__schema__(:read_after_writes) == [:email, :count]
     assert Schema.__schema__(:primary_key) == [:id]
@@ -73,7 +77,8 @@ defmodule Ecto.SchemaTest do
                id: :id,
                uuid: Ecto.UUID,
                no_query_load: :string,
-               read_only: :string
+               unwritable: :string,
+               non_updatable: :string
              }
   end
 
@@ -589,13 +594,25 @@ defmodule Ecto.SchemaTest do
                  end
 
     assert_raise ArgumentError,
-                 "cannot mark the same field as autogenerate and read only",
+                 "autogenerated fields must always be writable",
+                 fn ->
+                   defmodule AutogenerateFail do
+                     use Ecto.Schema
+
+                     schema "hello" do
+                       field :x, Ecto.UUID, autogenerate: true, writable: :never
+                     end
+                   end
+                 end
+
+     assert_raise ArgumentError,
+                 "autogenerated fields must always be writable",
                  fn ->
                    defmodule AutogenerateFail do
                      use Ecto.Schema
 
                      schema "hello" do
-                       field :x, Ecto.UUID, autogenerate: true, read_only: true
+                       field :x, Ecto.UUID, autogenerate: true, writable: :insert
                      end
                    end
                  end