Repository: elixir-ecto/ecto (elixir)

Issue:
Ecto.Changeset.merge do not merge types and do not raises when types are different
### Elixir version

1.16.1

### Database and Version

PostgreSQL: postgres:13-alpine

### Ecto Versions

3.11.2

### Database Adapter and Versions (postgrex, myxql, etc)

postgrex 0.17.4

### Current behavior

During the `Ecto.Changeset.merge/2` operation the type field of the changeset is not merged. Which result in a strange result when applying changes using schemaless changesets

```elixir
data = %{}
changeset1 = Ecto.Changeset.cast({data, %{field1: :string}}, %{field1: "field 1"}, [:field1])
changeset2 = Ecto.Changeset.cast({data, %{field2: :string}}, %{field2: "field 2"}, [:field2])
Ecto.Changeset.merge(changeset1, changeset2) |> Map.get(:types)
# %{field2: :string}

Ecto.Changeset.merge(changeset1, changeset2) |> Ecto.Changeset.apply_changes()
# %{field2: "field 2"}
```

Is it the expected behaviour?
I see in the code the merging of types is performed via `Kernel.||/2` and not a `Map.merge/2` operation

```elixir
  defp cast_merge(cs1, cs2) do
    ...
    new_types = cs1.types || cs2.types
    ...
  end 
```

### Expected behavior

I can see 2 types of behaviour that could be acceptable:

1. Merge the types for easiness to use merge with schema less changeset
2. Raise if the types are not equals (like when the data from the changesets are different)

Pull request:
Merge types during `Ecto.Changeset.merge/2`
This commit adds support for merging types when calling `Ecto.Changeset.merge/2`.

Closes #4424

Patch:
diff --git a/lib/ecto/changeset.ex b/lib/ecto/changeset.ex
index 2665c891..8d3c2feb 100644
--- a/lib/ecto/changeset.ex
+++ b/lib/ecto/changeset.ex
@@ -360,7 +360,7 @@ defmodule Ecto.Changeset do
             constraints: [],
             filters: %{},
             action: nil,
-            types: nil,
+            types: %{},
             empty_values: @empty_values,
             repo: nil,
             repo_opts: []
@@ -379,7 +379,7 @@ defmodule Ecto.Changeset do
           validations: [validation],
           filters: %{optional(atom) => term},
           action: action,
-          types: nil | %{atom => Ecto.Type.t() | {:assoc, term()} | {:embed, term()}}
+          types: types
         }
 
   @type t :: t(Ecto.Schema.t() | map | nil)
@@ -394,7 +394,7 @@ defmodule Ecto.Changeset do
           error_type: atom
         }
   @type data :: map()
-  @type types :: map()
+  @type types :: %{atom => Ecto.Type.t() | {:assoc, term()} | {:embed, term()}}
   @type traverse_result :: %{atom => [term] | traverse_result}
   @type validation :: {atom, term}
 
@@ -480,10 +480,6 @@ defmodule Ecto.Changeset do
     change(%Changeset{data: data, types: Enum.into(types, %{}), valid?: true}, changes)
   end
 
-  def change(%Changeset{types: nil}, _changes) do
-    raise ArgumentError, "changeset does not have types information"
-  end
-
   def change(%Changeset{changes: changes, types: types} = changeset, new_changes)
       when is_map(new_changes) or is_list(new_changes) do
     {changes, errors, valid?} =
@@ -739,10 +735,6 @@ defmodule Ecto.Changeset do
     cast(data, types, %{}, params, permitted, opts)
   end
 
-  def cast(%Changeset{types: nil}, _params, _permitted, _opts) do
-    raise ArgumentError, "changeset does not have types information"
-  end
-
   def cast(%Changeset{} = changeset, params, permitted, opts) do
     %{changes: changes, data: data, types: types, empty_values: empty_values} = changeset
 
@@ -1523,10 +1515,10 @@ defmodule Ecto.Changeset do
 
   defp cast_merge(cs1, cs2) do
     new_params = (cs1.params || cs2.params) && Map.merge(cs1.params || %{}, cs2.params || %{})
+    new_types = Map.merge(cs1.types, cs2.types)
     new_changes = Map.merge(cs1.changes, cs2.changes)
     new_errors = Enum.uniq(cs1.errors ++ cs2.errors)
     new_required = Enum.uniq(cs1.required ++ cs2.required)
-    new_types = cs1.types || cs2.types
     new_valid? = cs1.valid? and cs2.valid?
 
     %{
@@ -1743,10 +1735,6 @@ defmodule Ecto.Changeset do
     get_relation(:embed, changeset, name)
   end
 
-  defp get_relation(_tag, %{types: nil}, _name) do
-    raise ArgumentError, "changeset does not have types information"
-  end
-
   defp get_relation(tag, %{changes: changes, data: data, types: types}, name) do
     _ = relation!(:get, tag, name, Map.get(types, name))
 
@@ -1886,10 +1874,6 @@ defmodule Ecto.Changeset do
 
   """
   @spec put_change(t, atom, term) :: t
-  def put_change(%Changeset{types: nil}, _key, _value) do
-    raise ArgumentError, "changeset does not have types information"
-  end
-
   def put_change(%Changeset{data: data, types: types} = changeset, key, value) do
     type = Map.get(types, key)
 
@@ -2153,10 +2137,6 @@ defmodule Ecto.Changeset do
     put_relation(:embed, changeset, name, value, opts)
   end
 
-  defp put_relation(_tag, %{types: nil}, _name, _value, _opts) do
-    raise ArgumentError, "changeset does not have types information"
-  end
-
   defp put_relation(tag, changeset, name, value, _opts) do
     %{data: data, types: types, changes: changes, errors: errors, valid?: valid?} = changeset
     relation = relation!(:put, tag, name, Map.get(types, name))
@@ -2189,10 +2169,6 @@ defmodule Ecto.Changeset do
 
   """
   @spec force_change(t, atom, term) :: t
-  def force_change(%Changeset{types: nil}, _key, _value) do
-    raise ArgumentError, "changeset does not have types information"
-  end
-
   def force_change(%Changeset{types: types} = changeset, key, value) do
     case Map.get(types, key) do
       {tag, _} when tag in @relations ->


Test patch:
diff --git a/test/ecto/changeset_test.exs b/test/ecto/changeset_test.exs
index 531857c4..c290bf80 100644
--- a/test/ecto/changeset_test.exs
+++ b/test/ecto/changeset_test.exs
@@ -661,6 +661,12 @@ defmodule Ecto.ChangesetTest do
     assert length(constraints(changeset)) == 2
   end
 
+  test "merge/2: merges types" do
+    cs1 = cast({%{}, %{title: :string}}, %{title: "foo"}, ~w(title)a)
+    cs2 = cast({%{}, %{body: :string}}, %{body: "foo"}, ~w(body)a)
+    assert merge(cs1, cs2).types == %{title: :string, body: :string}
+  end
+
   test "merge/2: merges parameters" do
     empty = cast(%Post{}, %{}, ~w(title)a)
     cs1 = cast(%Post{}, %{body: "foo"}, ~w(body)a)