(I’m using Ecto 3 {:ecto_sql, "~> 3.0"}
, although I don’t think that’s related to the problem.)
I want to store a list of “notices” in my DB model to record issues with (e.g.) unclean data. You can think of it like Ecto changeset errors: a tag (e.g. attribute name) associated to an array of these notices.
I’m trying to use a custom type for this, but when the custom type is nested within other native DB types, it doesn’t appear to get put into the expected struct when loaded from the DB.
Here’s my type definition:
defmodule TypeDemo.Notice do
@behaviour Ecto.Type
@enforce_keys [:message, :values]
@derive {Jason.Encoder, only: [:message, :values]}
defstruct [:message, :values]
def new(message, %{} = values \\ %{}) when is_binary(message) do
%__MODULE__{
message: message,
values: values
}
end
@impl Ecto.Type
def type, do: :map
@impl Ecto.Type
def cast(%{"message" => message, "values" => values})
when is_binary(message) and is_map(values) do
{:ok, struct!(__MODULE__, %{message: message, values: values})}
end
def cast(%__MODULE__{} = notice), do: {:ok, notice}
def cast(_), do: :error
@impl Ecto.Type
def load(data) when is_map(data) do
data =
for {key, val} <- data do
{String.to_existing_atom(key), val}
end
|> Enum.into(%{})
values =
for {key, val} <- data.values do
{String.to_existing_atom(key), val}
end
|> Enum.into(%{})
{:ok, struct!(__MODULE__, %{data | values: values})}
end
@impl Ecto.Type
def dump(%__MODULE__{} = notice), do: Ecto.Type.dump(:map, notice)
def dump(_), do: :error
end
Here’s the relevant migration:
defmodule TypeDemo.Repo.Migrations.AddPosts do
use Ecto.Migration
def change do
create table("posts") do
add(:name, :string, null: false)
add(:notice, :map, null: false, default: %{})
add(:issues, {:map, {:array, :map}}, null: false)
end
end
end
And here’s the “model”:
defmodule TypeDemo.Post do
use Ecto.Schema
import Ecto.Changeset
alias TypeDemo.Notice
schema "posts" do
field(:name, :string)
field(:notice, Notice)
field(:issues, {:map, {:array, Notice}})
end
@doc false
def changeset(changeset, attrs) do
changeset
|> cast(attrs, [
:name,
:notice,
:issues
])
|> validate_required([
:name
])
end
end
And here’s an IEx session:
import Ecto.{Changeset, Query}
alias TypeDemo.{Repo, Post, Notice}
notice = %Notice{message: "notice message", values: %{foo: :bar}}
base = change(%Post{}, %{name: "some name"})
c = TypeDemo.Post.changeset(base, %{issues: %{name: [notice, notice]}, notice: notice})
{:ok, post} = Repo.insert(c)
Which outputs:
08:50:52.007 [debug] QUERY OK db=19.7ms decode=2.4ms queue=0.9ms
INSERT INTO "posts" ("issues","name","notice") VALUES ($1,$2,$3) RETURNING "id" [%{name: [%TypeDemo.Notice{message: "notice message", values: %{foo: :bar}}, %TypeDemo.Notice{message: "notice message", values: %{foo: :bar}}]}, "some name", %TypeDemo.Notice{message: "notice message", values: %{foo: :bar}}]
{:ok,
%TypeDemo.Post{
__meta__: #Ecto.Schema.Metadata<:loaded, "posts">,
id: 1,
issues: %{
name: [
%TypeDemo.Notice{message: "notice message", values: %{foo: :bar}},
%TypeDemo.Notice{message: "notice message", values: %{foo: :bar}}
]
},
name: "some name",
notice: %TypeDemo.Notice{message: "notice message", values: %{foo: :bar}}
}}
iex(3)> post
%TypeDemo.Post{
__meta__: #Ecto.Schema.Metadata<:loaded, "posts">,
id: 1,
issues: %{
name: [
%TypeDemo.Notice{message: "notice message", values: %{foo: :bar}},
%TypeDemo.Notice{message: "notice message", values: %{foo: :bar}}
]
},
name: "some name",
notice: %TypeDemo.Notice{message: "notice message", values: %{foo: :bar}}
}
As you can see, the various Notice
instances have been properly cast. But here’s the problem: executing post = Repo.one(from p in Post, where: p.id == 1)
yields:
%TypeDemo.Post{
__meta__: #Ecto.Schema.Metadata<:loaded, "posts">,
id: 1,
issues: %{
"name" => [
%{"message" => "notice message", "values" => %{"foo" => "bar"}},
%{"message" => "notice message", "values" => %{"foo" => "bar"}}
]
},
name: "some name",
notice: %TypeDemo.Notice{message: "notice message", values: %{foo: "bar"}}
}
While the notice
attribute was properly loaded and converted into the Notice
struct, the nested versions in issues
were not.
So: am I missing something or doing something wrong? Is this expected behavior from Ecto?